diff --git a/Gruntfile.js b/Gruntfile.js index 284a6a4f2f..ccf8b46dba 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -88,7 +88,7 @@ module.exports = (grunt) => { ;(function defineApiEnvars () { const API_PORT = Number.parseInt(grunt.option('port') ?? process.env.API_PORT ?? '8000', 10) - if (Number.isNaN(API_PORT) || API_PORT < 8000 || API_PORT > 65535) { + if (Number.isNaN(API_PORT) || API_PORT < 1024 || API_PORT > 65535) { throw new RangeError(`Invalid API_PORT value: ${API_PORT}.`) } process.env.API_PORT = String(API_PORT) diff --git a/backend/database.js b/backend/database.js index 19740e08e4..f4766e3968 100644 --- a/backend/database.js +++ b/backend/database.js @@ -9,6 +9,7 @@ import path from 'node:path' import '@sbp/okturtles.data' import { checkKey, parsePrefixableKey, prefixHandlers } from '~/shared/domains/chelonia/db.js' import LRU from 'lru-cache' +import { initVapid } from './vapid.js' import { initZkpp } from './zkppSalt.js' const Boom = require('@hapi/boom') @@ -211,5 +212,5 @@ export default async () => { } numNewKeys && console.info(`[chelonia.db] Preloaded ${numNewKeys} new entries`) } - await initZkpp() + await Promise.all([initVapid(), initZkpp()]) } diff --git a/backend/pubsub.js b/backend/pubsub.js index 11d317fc6d..3b22cb7173 100644 --- a/backend/pubsub.js +++ b/backend/pubsub.js @@ -21,6 +21,7 @@ import type { } from '~/shared/pubsub.js' import type { JSONType, JSONObject } from '~/shared/types.js' +import { postEvent } from './push.js' const { bold } = require('chalk') const WebSocket = require('ws') @@ -105,7 +106,7 @@ export function createServer (httpServer: Object, options?: Object = {}): Object server.channels = new Set() server.customServerEventHandlers = { ...options.serverHandlers } server.customSocketEventHandlers = { ...options.socketHandlers } - server.messageHandlers = { ...defaultMessageHandlers, ...options.messageHandlers } + server.customMessageHandlers = { ...options.messageHandlers } server.pingIntervalID = undefined server.subscribersByChannelID = Object.create(null) server.pushSubscriptions = Object.create(null) @@ -163,11 +164,22 @@ const defaultServerHandlers = { const url = request.url const urlSearch = url.includes('?') ? url.slice(url.lastIndexOf('?')) : '' const debugID = new URLSearchParams(urlSearch).get('debugID') || '' + const send = socket.send.bind(socket) socket.id = generateSocketID(debugID) socket.activeSinceLastPing = true socket.pinged = false socket.server = server socket.subscriptions = new Set() + // Sometimes (like when using `createMessage`), we want to send objects that + // are serialized as strings. The `ws` library sends these as binary data, + // whereas the client expects strings. This avoids having to manually + // specify `{ binary: false }` along with calls. + socket.send = function (data) { + if (typeof data === 'object' && typeof data[Symbol.toPrimitive] === 'function') { + return send(data[Symbol.toPrimitive]()) + } + return send(data) + } log.bold(`Socket ${socket.id} connected. Total: ${this.clients.size}`) @@ -231,14 +243,16 @@ const defaultSocketEventHandlers = { } // The socket can be marked as active since it just received a message. socket.activeSinceLastPing = true - const handler = server.messageHandlers[msg.type] + const defaultHandler = defaultMessageHandlers[msg.type] + const customHandler = server.customMessageHandlers[msg.type] - if (handler) { + if (defaultHandler || customHandler) { try { - handler.call(socket, msg) + defaultHandler?.call(socket, msg) + customHandler?.call(socket, msg) } catch (error) { // Log the error message and stack trace but do not send it to the client. - log.error(error, 'onMesage') + log.error(error, 'onMessage') server.rejectMessageAndTerminateSocket(msg, socket) } } else { @@ -322,9 +336,57 @@ const publicMethods = { ) { const server = this + const msg = typeof message === 'string' ? message : JSON.stringify(message) + let shortMsg + // Utility function to remove `data` (i.e., the GIMessage data) from a + // message. We need this for push notifications, which may have a certain + // maximum size (usually around 4 KiB) + const shortenPayload = () => { + if (!shortMsg && (typeof message === 'object' && message.type === NOTIFICATION_TYPE.ENTRY && message.data)) { + delete message.data + shortMsg = JSON.stringify(message) + } + return shortMsg + } + for (const client of to || server.clients) { + // `client` could be either a WebSocket or a wrapped subscription info + // object + // Duplicate message sending (over both WS and push) is handled on the + // WS logic, for the `close` event (to remove the WS and send over push) + // and for the `STORE_SUBSCRIPTION` WS action. + if (client.endpoint) { + // `client.endpoint` means the client is a subscription info object + // The max length for push notifications in many providers is 4 KiB. + // However, encrypting adds a slight overhead of 17 bytes at the end + // and 86 bytes at the start. + if (msg.length > (4096 - 86 - 17)) { + if (!shortenPayload()) { + console.info('Skipping too large of a payload for', client.id) + continue + } + } + postEvent(client, shortMsg || msg).catch(e => { + // If we have an error posting due to too large of a payload and the + // message wasn't already shortened, try again + if (e?.message === 'Payload too large') { + if (shortMsg || !shortenPayload()) { + // The max length for push notifications in many providers is 4 KiB. + console.info('Skipping too large of a payload for', client.id) + return + } + postEvent(client, shortMsg).catch(e => { + console.error(e, 'Error posting push notification') + }) + return + } + console.error(e, 'Error posting push notification') + }) + continue + } if (client.readyState === WebSocket.OPEN && client !== except) { - client.send(typeof message === 'string' ? message : JSON.stringify(message)) + // In this branch, we're dealing with a WebSocket + client.send(msg) } } }, diff --git a/backend/push.js b/backend/push.js index 2fee51e06b..872b1100df 100644 --- a/backend/push.js +++ b/backend/push.js @@ -1,53 +1,346 @@ -const pushController = require('web-push') -const giConfig = require('../giconf.json') +import { aes128gcm } from '@apeleghq/rfc8188/encodings' +import encrypt from '@apeleghq/rfc8188/encrypt' +import sbp from '@sbp/sbp' +import { PUBSUB_INSTANCE } from './instance-keys.js' +import rfc8291Ikm from './rfc8291Ikm.js' +import { getVapidPublicKey, vapidAuthorization } from './vapid.js' + +// const pushController = require('web-push') const { PUSH_SERVER_ACTION_TYPE, REQUEST_TYPE, createMessage } = require('../shared/pubsub.js') -// NOTE: VAPID public/private keys can be generated via 'npx web-push generate-vapid-keys' command. -const publicKey = process.env.VAPID_PUBLIC_KEY || giConfig.VAPID_PUBLIC_KEY -const privateKey = process.env.VAPID_PRIVATE_KEY || giConfig.VAPID_PRIVATE_KEY -pushController.setVapidDetails( - process.env.VAPID_EMAIL || giConfig.VAPID_EMAIL, - publicKey, - privateKey -) +const addSubscriptionToIndex = async (subcriptionId: string) => { + await sbp('okTurtles.eventQueue/queueEvent', 'update-webpush-indices', async () => { + const currentIndex = await sbp('chelonia/db/get', '_private_webpush_index') + // Add the current subscriptionId to the subscription index. Entries in the + // index are separated by \x00 (NUL). The index itself is used to know + // which entries to load. + const updatedIndex = `${currentIndex ? `${currentIndex}\x00` : ''}${subcriptionId}` + await sbp('chelonia/db/set', '_private_webpush_index', updatedIndex) + }) +} + +const deleteSubscriptionFromIndex = async (subcriptionId: string) => { + await sbp('okTurtles.eventQueue/queueEvent', 'update-webpush-indices', async () => { + const currentIndex = await sbp('chelonia/db/get', '_private_webpush_index') + const index = currentIndex.indexOf(subcriptionId) + if (index === -1) return + const updatedIndex = currentIndex.slice(0, index > 1 ? index - 1 : 0) + currentIndex.slice(index + subcriptionId.length) + await sbp('chelonia/db/set', '_private_webpush_index', updatedIndex) + }) +} + +const saveSubscription = (server, subscriptionId) => { + sbp('chelonia/db/set', `_private_webpush_${subscriptionId}`, JSON.stringify({ + subscription: server.pushSubscriptions[subscriptionId], + channelIDs: [...server.pushSubscriptions[subscriptionId].subscriptions] + })).catch(e => { + console.error(e, 'Error saving subscription', subscriptionId) + }) +} + +export const addChannelToSubscription = (server: Object, subscriptionId: string, channelID: string): void => { + server.pushSubscriptions[subscriptionId].subscriptions.add(channelID) + saveSubscription(server, subscriptionId) +} + +export const deleteChannelFromSubscription = (server: Object, subscriptionId: string, channelID: string): void => { + server.pushSubscriptions[subscriptionId].subscriptions.delete(channelID) + saveSubscription(server, subscriptionId) +} + +// Generate an UUID from a `PushSubscription' +export const getSubscriptionId = async (subscriptionInfo: Object): Promise => { + const textEncoder = new TextEncoder() + // + const endpoint = textEncoder.encode(subscriptionInfo.endpoint) + // + const p256dh = textEncoder.encode(subscriptionInfo.keys.p256dh) + const auth = textEncoder.encode(subscriptionInfo.keys.auth) + + const canonicalForm = new ArrayBuffer( + 8 + + (4 + endpoint.byteLength) + (2 + p256dh.byteLength) + + (2 + auth.byteLength) + ) + const canonicalFormU8 = new Uint8Array(canonicalForm) + const canonicalFormDV = new DataView(canonicalForm) + let offset = 0 + canonicalFormDV.setFloat64( + offset, + subscriptionInfo.expirationTime == null + ? NaN + : subscriptionInfo.expirationTime, + false + ) + offset += 8 + canonicalFormDV.setUint32(offset, endpoint.byteLength, false) + offset += 4 + canonicalFormU8.set(endpoint, offset) + offset += endpoint.byteLength + canonicalFormDV.setUint16(offset, p256dh.byteLength, false) + offset += 2 + canonicalFormU8.set(p256dh, offset) + offset += p256dh.byteLength + canonicalFormDV.setUint16(offset, auth.byteLength, false) + offset += 2 + canonicalFormU8.set(auth, offset) + + const digest = await crypto.subtle.digest('SHA-384', canonicalForm) + const id = Buffer.from(digest.slice(0, 16)) + id[6] = 0x80 | (id[6] & 0x0F) + id[8] = 0x80 | (id[8] & 0x3F) + + return [ + id.slice(0, 4), + id.slice(4, 6), + id.slice(6, 8), + id.slice(8, 10), + id.slice(10, 16) + ].map((p) => p.toString('hex')).join('-') +} + +// Wrap a SubscriptionInfo object to include a subscription ID and encryption +// keys +export const subscriptionInfoWrapper = (subcriptionId: string, subscriptionInfo: Object, channelIDs: ?string[]): Object => { + subscriptionInfo.endpoint = new URL(subscriptionInfo.endpoint) + + Object.defineProperties(subscriptionInfo, { + 'id': { + get () { + return subcriptionId + } + }, + // These encryption keys are used for encrypting push notification bodies + // and are unrelated to VAPID, which is used for provenance. + 'encryptionKeys': { + get: (() => { + let count = 0 + let resultPromise + let salt + let uaPublic + + return function (this: Object) { + // Rotate encryption keys every 2**32 messages + // This is just a precaution for a birthday attack, which reduces the + // odds of a collision due to salt reuse to under 10**-18. + if ((count | 0) === 0) { + if (!salt) { + // `this.keys.auth` is a salt value that comes from the browser + // $FlowFixMe[incompatible-call] + salt = Buffer.from(this.keys.auth, 'base64url') + } + if (!uaPublic) { + // `this.keys.p256dh` is a public key that is browser-generated + // $FlowFixMe[incompatible-call] + uaPublic = Buffer.from(this.keys.p256dh, 'base64url') + } + + // When we send web push notications, they must be encrypted. The + // `rfc8291Ikm` function will derive encryption keys based on + // information orginating from the web push client (i.e., the + // browser, which is the `uaPublic` and `salt` parameters), and a + // server encryption key. + resultPromise = rfc8291Ikm(uaPublic, salt) + count = 1 + } else { + count++ + } + + return resultPromise + } + })() + }, + 'sockets': { + value: new Set() + }, + 'subscriptions': { + value: new Set(channelIDs) + } + }) + + Object.freeze(subscriptionInfo) + + return subscriptionInfo +} + +const removeSubscription = (server, subscriptionId) => { + const subscription = server.pushSubscriptions[subscriptionId] + delete server.pushSubscriptions[subscriptionId] + if (server.subscribersByChannelID) { + subscription.subscriptions.forEach((channelID) => { + server.subscribersByChannelID[channelID].delete(subscription) + }) + } + deleteSubscriptionFromIndex(subscriptionId).then(() => { + return sbp('chelonia/db/delete', `_private_webpush_${subscriptionId}`) + }).catch((e) => console.error(e, 'Error removing subscription', subscriptionId)) +} + +const deleteClient = (subscriptionId) => { + const server = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + removeSubscription(server, subscriptionId) +} + +// Web push subscriptions (that contain a body) are mandatorily encrypted. The +// encryption method and keys used are described by RFC 8188 and RFC 8291. +// The encryption keys used are derived from a EC key pair (browser-generated), +// a salt (browser-generated) and a second EC key pair (server-generated). +// The browser generated parameters are sent over to us via the WebSocket. +// Although they should be protected, their compromise doesn't mean that +// confidentiality of past or future messages is affected, since only the EC +// public component is sent over the network. +// The encryption keys used correspond to a specific client. Although they are +// supposed to identify a particular client, there is no way to make sure that +// there isn't a MitM. However, we don't really send information as push +// push notifications that isn't already public or could be derived from other +// public sources. The main concern if the encryption is compromised would be +// the ability to infer which channels a client is subscribed to. +const encryptPayload = async (subcription: Object, data: string) => { + const readableStream = new Response(data).body + const [asPublic, IKM] = await subcription.encryptionKeys + + return encrypt(aes128gcm, readableStream, 32768, asPublic, IKM).then(async (bodyStream) => { + const chunks = [] + const reader = bodyStream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(new Uint8Array(value)) + } + return Buffer.concat(chunks) + }) +} + +export const postEvent = async (subscription: Object, event: ?string): Promise => { + const authorization = await vapidAuthorization(subscription.endpoint) + // Note: web push notifications can be 'bodyless' or they can contain a body + // If there's no body, there isn't anything to encrypt, so we skip both the + // encryption and the encryption headers. + const body = event + ? await encryptPayload(subscription, event) + : undefined + + const req = await fetch(subscription.endpoint, { + method: 'POST', + headers: [ + ['authorization', authorization], + ...(body + ? [['content-encoding', 'aes128gcm'], + [ + 'content-type', + 'application/octet-stream' + ] + ] + : []), + // ['push-receipt', ''], + ['ttl', '60'] + ], + body + }) + + if (!req.ok) { + // If the response was 401 (Unauthorized), 404 (Not found) or 410 (Gone), + // it likely means that the subscription no longer exists. + if ([401, 404, 410].includes(req.status)) { + console.warn( + new Date().toISOString(), + 'Removing subscription', + subscription.id + ) + deleteClient(subscription.id) + return + } + if (req.status === 413) { + throw new Error('Payload too large') + } + } +} export const pushServerActionhandlers: any = { [PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY] () { const socket = this - socket.send(createMessage(REQUEST_TYPE.PUSH_ACTION, publicKey)) + socket.send(createMessage(REQUEST_TYPE.PUSH_ACTION, { type: PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY, data: getVapidPublicKey() })) }, - [PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION] (payload) { + async [PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION] (payload) { const socket = this - const subscription = JSON.parse(payload) + const { server } = socket + const subscription = payload + const subscriptionId = await getSubscriptionId(subscription) - // Reference: is it safe to use 'endpoint' as a unique identifier of a push subscription - // (https://stackoverflow.com/questions/63767889/is-it-safe-to-use-the-p256dh-or-endpoint-keys-values-of-the-push-notificatio) - socket.server.pushSubscriptions[subscription.endpoint] = subscription + if (!server.pushSubscriptions[subscriptionId]) { + // If this is a new subscription, we call `subscriptionInfoWrapper` and + // store it in memory. + server.pushSubscriptions[subscriptionId] = subscriptionInfoWrapper(subscriptionId, subscription) + addSubscriptionToIndex(subscriptionId).then(() => { + return sbp('chelonia/db/set', `_private_webpush_${subscriptionId}`, JSON.stringify({ subscription: subscription, channelIDs: [] })) + }).catch((e) => console.error(e, 'Error saving subscription', subscriptionId)) + // Send an initial push notification to verify that the endpoint works + // This is mostly for testing to be able to auto-remove invalid or expired + // endpoints. This doesn't need more error handling than any other failed + // call to `postEvent`. + postEvent(server.pushSubscriptions[subscriptionId], JSON.stringify({ type: 'initial' })).catch(e => console.warn(e, 'Error sending initial push notification')) + } else { + // Otherwise, if this is an _existing_ push subscription, we don't need + // to call `subscriptionInfoWrapper` but we need to stop sending messages + // over the push subscription (since we now have a WS to use, the one + // over which the message came). + // We expect `server.pushSubscriptions[subscriptionId].sockets.size` to + // be `0` when the WS connection has been closed and has since reconnected + // If it's not 0, we've already run this code at least once and we don't + // need to run it again. + if (server.pushSubscriptions[subscriptionId].sockets.size === 0) { + server.pushSubscriptions[subscriptionId].subscriptions.forEach((channelID) => { + if (!server.subscribersByChannelID[channelID]) return + server.subscribersByChannelID[channelID].delete(server.pushSubscriptions[subscriptionId]) + }) + } + } + // If the WS has an associated push subscription that's different from the + // one we've received, we need to 'switch' the WS to be associated with the + // new push subscription instead. + if (socket.pushSubscriptionId) { + if (socket.pushSubscriptionId === subscriptionId) return + // Since the subscription has been updated, remove the old one on the + // assumption that it's no longer valid + removeSubscription(server, subscriptionId) + /* + // The code below is an alternative to removing the subscription, which is + // safer but can result in accumulating old subscriptions forever. What it + // does is treat it as a closed WS, meaning that the current WS will be + // associated with the subscription info we've just received, but we'll + // start sending messages to the old subscription as if it had been closed. + const oldSubscriptionId = socket.pushSubscriptionId + server.pushSubscriptions[oldSubscriptionId].sockets.delete(socket) + if (server.pushSubscriptions[oldSubscriptionId].sockets.size === 0) { + server.pushSubscriptions[oldSubscriptionId].subscriptions.forEach((channelID) => { + if (!server.subscribersByChannelID[channelID]) { + server.subscribersByChannelID[channelID] = new Set() + } + server.subscribersByChannelID[channelID].add(server.pushSubscriptions[oldSubscriptionId]) + }) + } + */ + } + // Now, we're almost done setting things up. We'll link together the push + // subscription and the WS and add all existing channel subscriptions to the + // web push subscription (so that we can easily switch over if / when the + // WS is closed, see the `close` () function in socketHandlers in server.js) + socket.pushSubscriptionId = subscriptionId + server.pushSubscriptions[subscriptionId].subscriptions.forEach((channelID) => { + server.subscribersByChannelID?.[channelID].delete(server.pushSubscriptions[subscriptionId]) + }) + server.pushSubscriptions[subscriptionId].sockets.add(socket) + socket.subscriptions?.forEach(channelID => { + server.pushSubscriptions[subscriptionId].subscriptions.add(channelID) + }) + saveSubscription(server, subscriptionId) }, - [PUSH_SERVER_ACTION_TYPE.DELETE_SUBSCRIPTION] (payload) { + [PUSH_SERVER_ACTION_TYPE.DELETE_SUBSCRIPTION] () { const socket = this - const subscriptionId = JSON.parse(payload) + const { server, pushSubscriptionId: subscriptionId } = socket - delete socket.server.pushSubscriptions[subscriptionId] - }, - [PUSH_SERVER_ACTION_TYPE.SEND_PUSH_NOTIFICATION]: async function (payload) { - const pushSubscriptions = this.server.pushSubscriptions - const data = JSON.parse(payload) - const sendPush = (sub) => pushController.sendNotification( - sub, JSON.stringify({ title: data.title, body: data.body }) - ) - - // NOTE: if the payload contains 'endpoint' field, send push-notification to that particular subscription. - // otherwise, iterate all existing subscriptions and broadcast the push-notification to all. - - if (data.endpoint) { - const subscription = pushSubscriptions[data.endpoint] - - await sendPush(subscription) - } else { - for (const subscription of Object.values(pushSubscriptions)) { - await sendPush(subscription) - } + if (subscriptionId) { + removeSubscription(server, subscriptionId) } } } diff --git a/backend/rfc8291Ikm.js b/backend/rfc8291Ikm.js new file mode 100644 index 0000000000..7deb21007d --- /dev/null +++ b/backend/rfc8291Ikm.js @@ -0,0 +1,80 @@ +// Key derivation as per RFC 8291 (for sending encrypted push notifications) +export default async (uaPublic: Uint8Array, salt: Uint8Array): Promise<[ArrayBuffer, ArrayBuffer]> => { + const [[asPrivateKey, asPublic], uaPublicKey] = await Promise.all([ + crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256' + }, + false, + ['deriveKey'] + ).then(async (asKeyPair) => { + const asPublic = await crypto.subtle.exportKey( + 'raw', + asKeyPair.publicKey + ) + + return [asKeyPair.privateKey, asPublic] + }), + crypto.subtle.importKey( + 'raw', + uaPublic, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ) + ]) + + const ecdhSecret = await crypto.subtle.deriveKey( + { + name: 'ECDH', + public: uaPublicKey + }, + asPrivateKey, + { + name: 'HKDF', + hash: 'SHA-256' + }, + false, + ['deriveBits'] + ) + + // The `WebPush: info\x00` string + const infoString = new Uint8Array([ + 0x57, + 0x65, + 0x62, + 0x50, + 0x75, + 0x73, + 0x68, + 0x3a, + 0x20, + 0x69, + 0x6e, + 0x66, + 0x6f, + 0x00 + ]) + const info = new Uint8Array(infoString.byteLength + uaPublic.byteLength + asPublic.byteLength) + info.set(infoString, 0) + info.set(uaPublic, infoString.byteLength) + info.set( + new Uint8Array(asPublic), + infoString.byteLength + uaPublic.byteLength + ) + + const IKM = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt, + info + }, + ecdhSecret, + 32 << 3 + ) + + // Role in RFC8188: `asPublic` is used as key ID, IKM as IKM. + return [asPublic, IKM] +} diff --git a/backend/routes.js b/backend/routes.js index c04bad6cd3..d2c51c0843 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -646,7 +646,7 @@ route.POST('/kv/{contractID}/{key}', { const existingSize = existing ? Buffer.from(existing).byteLength : 0 await sbp('chelonia/db/set', `_private_kv_${contractID}_${key}`, request.payload) await sbp('backend/server/updateSize', contractID, request.payload.byteLength - existingSize) - await sbp('backend/server/broadcastKV', contractID, key, request.payload) + await sbp('backend/server/broadcastKV', contractID, key, request.payload.toString()) return h.response().code(204) }) diff --git a/backend/server.js b/backend/server.js index b20cb18e52..2fa1156089 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,10 +17,25 @@ import { createPushErrorResponse, createServer } from './pubsub.js' -import { pushServerActionhandlers } from './push.js' -// $FlowFixMe[cannot-resolve-module] -import { webcrypto } from 'node:crypto' +import { addChannelToSubscription, deleteChannelFromSubscription, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' + +// Node.js version 18 and lower don't have global.crypto defined +// by default +if ( + !('crypto' in global) && + typeof require === 'function' +) { + const { webcrypto } = require('crypto') + if (webcrypto) { + Object.defineProperty(global, 'crypto', { + 'enumerable': true, + 'configurable': true, + 'get': () => webcrypto + }) + } +} const { CONTRACTS_VERSION, GI_VERSION } = process.env @@ -70,7 +85,13 @@ sbp('sbp/selectors/register', { const contractID = deserializedHEAD.contractID const cheloniaState = sbp('chelonia/rootState') // If the contract has been removed or the height hasn't been updated, - // there's nothing to persist + // there's nothing to persist. + // If `!cheloniaState.contracts[contractID]`, the contract's been removed + // and therefore we shouldn't save it. + // If `cheloniaState.contracts[contractID].height < deserializedHEAD.head.height`, + // it means that the message wasn't processed (we'd expect the height to + // be `>=` than the message's height if so), and therefore we also shouldn't + // save it. if (!cheloniaState.contracts[contractID] || cheloniaState.contracts[contractID].height < deserializedHEAD.head.height) { return } @@ -130,8 +151,10 @@ sbp('sbp/selectors/register', { }, 'backend/server/broadcastEntry': async function (deserializedHEAD: Object, entry: string) { const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry) - const subscribers = pubsub.enumerateSubscribers(deserializedHEAD.contractID) + const contractID = deserializedHEAD.contractID + const contractType = sbp('chelonia/rootState').contracts[contractID]?.type + const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry, { contractID, contractType }) + const subscribers = pubsub.enumerateSubscribers(contractID) console.debug(chalk.blue.bold(`[pubsub] Broadcasting ${deserializedHEAD.description()}`)) await pubsub.broadcast(pubsubMessage, { to: subscribers }) }, @@ -182,7 +205,7 @@ sbp('sbp/selectors/register', { 'backend/server/saveDeletionToken': async function (resourceID: string) { const deletionTokenRaw = new Uint8Array(18) // $FlowFixMe[cannot-resolve-name] - webcrypto.getRandomValues(deletionTokenRaw) + crypto.getRandomValues(deletionTokenRaw) // $FlowFixMe[incompatible-call] const deletionToken = Buffer.from(deletionTokenRaw).toString('base64url') await sbp('chelonia/db/set', `_private_deletionToken_${resourceID}`, deletionToken) @@ -206,6 +229,32 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { socket.send(createNotification(NOTIFICATION_TYPE.VERSION_INFO, versionInfo)) } }, + socketHandlers: { + // The `close()` handler signals the server that the WS has been closed and + // that subsequent messages to subscribed channels should now be sent to its + // associated web push subscription, if it exists. + close () { + const socket = this + const { server } = this + + const subscriptionId = socket.pushSubscriptionId + + if (!subscriptionId) return + if (!server.pushSubscriptions[subscriptionId]) return + + server.pushSubscriptions[subscriptionId].sockets.delete(socket) + delete socket.pushSubscriptionId + + if (server.pushSubscriptions[subscriptionId].sockets.size === 0) { + server.pushSubscriptions[subscriptionId].subscriptions.forEach((channelID) => { + if (!server.subscribersByChannelID[channelID]) { + server.subscribersByChannelID[channelID] = new Set() + } + server.subscribersByChannelID[channelID].add(server.pushSubscriptions[subscriptionId]) + }) + } + } + }, messageHandlers: { [REQUEST_TYPE.PUSH_ACTION]: async function ({ data }) { const socket = this @@ -229,6 +278,42 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { } else { socket.send(createPushErrorResponse({ message: `No handler for the '${action}' action` })) } + }, + // This handler adds subscribed channels to the web push subscription + // associated with the WS, so that when the WS is closed we can continue + // sending messages as web push notifications. + [NOTIFICATION_TYPE.SUB] ({ channelID }: SubMessage) { + const socket = this + const { server } = this + + // If the WS doesn't have an associated push subscription, we're done + if (!socket.pushSubscriptionId) return + // If the WS has an associated push subscription that's since been + // removed, delete the association and return. + if (!server.pushSubscriptions[socket.pushSubscriptionId]) { + delete socket.pushSubscriptionId + return + } + + addChannelToSubscription(server, socket.pushSubscriptionId, channelID) + }, + // This handler removes subscribed channels from the web push subscription + // associated with the WS, so that when the WS is closed we don't send + // messages as web push notifications. + [NOTIFICATION_TYPE.UNSUB] ({ channelID }: UnsubMessage) { + const socket = this + const { server } = this + + // If the WS doesn't have an associated push subscription, we're done + if (!socket.pushSubscriptionId) return + // If the WS has an associated push subscription that's since been + // removed, delete the association and return. + if (!server.pushSubscriptions[socket.pushSubscriptionId]) { + delete socket.pushSubscriptionId + return + } + + deleteChannelFromSubscription(server, socket.pushSubscriptionId, channelID) } } })) @@ -259,6 +344,24 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { })) Object.assign(sbp('chelonia/rootState'), recoveredState) } + // Then, load push subscriptions + const savedWebPushIndex = await sbp('chelonia/db/get', '_private_webpush_index') + if (savedWebPushIndex) { + const { pushSubscriptions, subscribersByChannelID } = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + await Promise.all(savedWebPushIndex.split('\x00').map(async (subscriptionId) => { + const subscriptionSerialized = await sbp('chelonia/db/get', `_private_webpush_${subscriptionId}`) + if (!subscriptionSerialized) { + console.warn(`[server] missing state for subscriptionId ${subscriptionId} - skipping setup for this subscription`) + return + } + const { subscription, channelIDs } = JSON.parse(subscriptionSerialized) + pushSubscriptions[subscriptionId] = subscriptionInfoWrapper(subscriptionId, subscription, channelIDs) + channelIDs.forEach((channelID) => { + if (!subscribersByChannelID[channelID]) subscribersByChannelID[channelID] = new Set() + subscribersByChannelID[channelID].add(pushSubscriptions[subscriptionId]) + }) + })) + } // https://hapi.dev/tutorials/plugins await hapi.register([ { plugin: require('./auth.js') }, diff --git a/backend/vapid.js b/backend/vapid.js new file mode 100644 index 0000000000..1c56a292d9 --- /dev/null +++ b/backend/vapid.js @@ -0,0 +1,107 @@ +import sbp from '@sbp/sbp' + +let vapidPublicKey: string +let vapidPrivateKey: Object + +// The Voluntary Application Server Identification (VAPID) email field is "a +// stable identity for the application server" that "can be used by a push +// service to establish behavioral expectations for an application server" +// RFC 8292 +if (!process.env.VAPID_EMAIL) { + console.warn('Missing VAPID identification. Please set VAPID_EMAIL to a value like "mailto:some@example".') +} +const vapid = { VAPID_EMAIL: process.env.VAPID_EMAIL || 'mailto:test@example.com' } + +export const initVapid = async () => { + const vapidKeyPair = await sbp('chelonia/db/get', '_private_immutable_vapid_key').then(async (vapidKeyPair: string): Promise<[Object, string]> => { + if (!vapidKeyPair) { + // Generate a new ECDSA key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256' // Use P-256 curve + }, + true, // Whether the key is extractable + ['sign', 'verify'] // Usages + ) + + // Export the private key + const serializedKeyPair = await Promise.all([ + crypto.subtle.exportKey('jwk', keyPair.privateKey), + crypto.subtle.exportKey('raw', keyPair.publicKey).then((key) => + // $FlowFixMe[incompatible-call] + Buffer.from(key).toString('base64url') + ) + ]) + + return sbp('chelonia/db/set', '_private_immutable_vapid_key', JSON.stringify(serializedKeyPair)).then(() => { + return [keyPair.privateKey, serializedKeyPair[1]] + }) + } + + const serializedKeyPair = JSON.parse(vapidKeyPair) + return [ + await crypto.subtle.importKey( + 'jwk', + serializedKeyPair[0], + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ), + serializedKeyPair[1] + ] + }) + + vapidPrivateKey = vapidKeyPair[0] + vapidPublicKey = vapidKeyPair[1] +} + +const generateJwt = async (endpoint: URL): Promise => { + const now = Date.now() / 1e3 | 0 + + // `endpoint` is coerced into a URL in subscriptionInfoWrapper + // The audience is the origin, see RFC 8292 (VAPID) section 2: + // + const audience = endpoint.origin + + const header = Buffer.from(JSON.stringify( + Object.fromEntries([['typ', 'JWT'], ['alg', 'ES256']]) + // $FlowFixMe[incompatible-call] + )).toString('base64url') + const body = Buffer.from(JSON.stringify( + // We're expecting to use the JWT immediately. We set a 10-minute window + // for using the JWT (5 minutes into the past, 5 minutes into the future) + // to account for potential network delays and clock drift. + Object.fromEntries([ + // token audience + ['aud', audience], + // 'expiry' / 'not after' value for the token + ['exp', now + 300], + // (optional) issuance time for the token + ['iat', now], + // 'not before' value for the JWT + ['nbf', now - 300], + // URI used for identifying ourselves. This can be used by the push + // provider to get in touch in case of issues. + ['sub', vapid.VAPID_EMAIL] + ]) + // $FlowFixMe[incompatible-call] + )).toString('base64url') + + const signature = Buffer.from( + await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + vapidPrivateKey, + Buffer.from([header, body].join('.')) + ) + ).toString('base64url') + + return [header, body, signature].join('.') +} + +export const getVapidPublicKey = (): string => vapidPublicKey + +export const vapidAuthorization = async (endpoint: URL): Promise => { + const jwt = await generateJwt(endpoint) + return `vapid t=${jwt}, k=${vapidPublicKey}` +} diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 0280e2dcb8..f24c447ddc 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -21,10 +21,10 @@ sbp('okTurtles.events/on', MESSAGE_RECEIVE_RAW, ({ // If newMessage is undefined, it means that an existing message is being edited newMessage }) => { + const state = sbp('chelonia/contract/state', contractID) + const rootState = sbp('chelonia/rootState') const getters = sbp('state/vuex/getters') - const rootState = sbp('state/vuex/state') - const targetChatroomState = rootState[contractID] - const mentions = makeMentionFromUserID(getters.ourIdentityContractId) + const mentions = makeMentionFromUserID(rootState.loggedIn?.identityContractID) const msgData = newMessage || data const isMentionedMe = (!!newMessage || data.type === MESSAGE_TYPES.TEXT) && msgData.text && (msgData.text.includes(mentions.me) || msgData.text.includes(mentions.all)) @@ -44,10 +44,10 @@ sbp('okTurtles.events/on', MESSAGE_RECEIVE_RAW, ({ messageHash: msgData.hash, height: msgData.height, text: msgData.text, - isDMOrMention: isMentionedMe || targetChatroomState?.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE, + isDMOrMention: isMentionedMe || state.attributes?.type === CHATROOM_TYPES.DIRECT_MESSAGE, messageType: !newMessage ? MESSAGE_TYPES.TEXT : data.type, memberID: innerSigningContractID, - chatRoomName: getters.chatRoomAttributes.name + chatRoomName: state.attributes?.name }).catch(e => { console.error('[action/chatroom.js] Error on messageReceivePostEffect', e) }) @@ -216,8 +216,7 @@ export default (sbp('sbp/selectors/register', { } }, 'gi.actions/chatroom/shareNewKeys': (contractID: string, newKeys) => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + const state = sbp('chelonia/contract/state', contractID) const originatingContractID = state.attributes.groupContractID ? state.attributes.groupContractID : contractID @@ -254,44 +253,65 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/unpinMessage', L('Failed to unpin message.')), ...encryptedAction('gi.actions/chatroom/join', L('Failed to join chat channel.'), async (sendMessage, params, signingKeyId) => { const rootState = sbp('state/vuex/state') - const userID = params.data.memberID || rootState.loggedIn.identityContractID + const identityContractID = rootState.loggedIn.identityContractID + // We accept an array for memberID to aggregate all joins + const userIDs = ( + Array.isArray(params.data.memberID) ? params.data.memberID : [params.data.memberID] + // If the memberID isn't specified, it's ourselves joining. This is + // consistent with how the contract works and produces shorter messages + ).map(memberID => memberID == null ? identityContractID : memberID) // We need to read values from both the chatroom and the identity contracts' // state, so we call wait to run the rest of this function after all // operations in those contracts have completed - await sbp('chelonia/contract/wait', [params.contractID, userID]) + await sbp('chelonia/contract/retain', userIDs, { ephemeral: true }) + try { + await sbp('chelonia/contract/wait', params.contractID) - if (!userID || !has(rootState.contracts, userID)) { - throw new Error(`Unable to send gi.actions/chatroom/join on ${params.contractID} because user ID contract ${userID} is missing`) - } + userIDs.forEach(cID => { + if (!cID || !has(rootState.contracts, cID) || !has(rootState, cID)) { + throw new Error(`Unable to send gi.actions/chatroom/join on ${params.contractID} because user ID contract ${cID} is missing`) + } + }) - const CEKid = params.encryptionKeyId || await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') + const CEKid = params.encryptionKeyId || await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') - const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') - return await sbp('chelonia/out/atomic', { - ...params, - contractName: 'gi.contracts/chatroom', - data: [ + const userCSKids = await Promise.all(userIDs.map(async (cID) => + [cID, await sbp('chelonia/contract/currentKeyIdByName', cID, 'csk')] + )) + return await sbp('chelonia/out/atomic', { + ...params, + contractName: 'gi.contracts/chatroom', + data: [ // Add the user's CSK to the contract - [ - 'chelonia/out/keyAdd', { + [ + 'chelonia/out/keyAdd', { // TODO: Find a way to have this wrapping be done by Chelonia directly - data: [encryptedOutgoingData(params.contractID, CEKid, { - foreignKey: `sp:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, - id: userCSKid, - data: rootState[userID]._vm.authorizedKeys[userCSKid].data, - permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'], - allowedActions: '*', - purpose: ['sig'], - ringLevel: Number.MAX_SAFE_INTEGER, - name: `${userID}/${userCSKid}` - })] - } + data: userCSKids.map(([cID, cskID]: [string, string]) => encryptedOutgoingData(params.contractID, CEKid, { + foreignKey: `shelter:${encodeURIComponent(cID)}?keyName=${encodeURIComponent('csk')}`, + id: cskID, + data: rootState[cID]._vm.authorizedKeys[cskID].data, + permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'], + allowedActions: '*', + purpose: ['sig'], + ringLevel: Number.MAX_SAFE_INTEGER, + name: `${cID}/${cskID}` + })) + } + ], + ...userIDs.map(cID => sendMessage({ + ...params, + data: cID === identityContractID + ? {} + : { memberID: cID }, + returnInvocation: true + })) ], - sendMessage({ ...params, returnInvocation: true }) - ], - signingKeyId - }) + signingKeyId + }) + } finally { + await sbp('chelonia/contract/release', userIDs, { ephemeral: true }) + } }), ...encryptedAction('gi.actions/chatroom/rename', L('Failed to rename chat channel.')), ...encryptedAction('gi.actions/chatroom/changeDescription', L('Failed to change chat channel description.')), diff --git a/frontend/controller/actions/group-kv.js b/frontend/controller/actions/group-kv.js index e5b79ac48f..19681cf521 100644 --- a/frontend/controller/actions/group-kv.js +++ b/frontend/controller/actions/group-kv.js @@ -1,11 +1,14 @@ 'use strict' import sbp from '@sbp/sbp' import { KV_KEYS, LAST_LOGGED_IN_THROTTLE_WINDOW } from '~/frontend/utils/constants.js' -import { KV_QUEUE, ONLINE } from '~/frontend/utils/events.js' +import { KV_QUEUE, NEW_LAST_LOGGED_IN, ONLINE } from '~/frontend/utils/events.js' sbp('okTurtles.events/on', ONLINE, () => { + if (!sbp('state/vuex/state').loggedIn?.identityContractID) { + return + } sbp('gi.actions/group/kv/load').catch(e => { - console.error("Error from 'gi.actions/identity/kv/load' after reestablished connection:", e) + console.error("Error from 'gi.actions/group/kv/load' after reestablished connection:", e) }) }) @@ -34,20 +37,18 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/group/kv/loadLastLoggedIn': ({ contractID }: { contractID: string }) => { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const data = await sbp('gi.actions/group/kv/fetchLastLoggedIn', { contractID }) - // TODO: Can't use state/vuex/commit - sbp('state/vuex/commit', 'setLastLoggedIn', [contractID, data]) + sbp('okTurtles.events/emit', NEW_LAST_LOGGED_IN, [contractID, data]) }) }, 'gi.actions/group/kv/updateLastLoggedIn': ({ contractID, throttle }: { contractID: string, throttle: boolean }) => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to update lastLoggedIn without an active session') } if (throttle) { - // TODO: Can't use state/vuex/state const state = sbp('state/vuex/state') - const lastLoggedIn = new Date(state.lastLoggedIn[contractID]?.[identityContractID]).getTime() + const lastLoggedIn = new Date(state.lastLoggedIn?.[contractID]?.[identityContractID]).getTime() if ((Date.now() - lastLoggedIn) < LAST_LOGGED_IN_THROTTLE_WINDOW) return } diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 3f40c8f72b..62f9442709 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -32,6 +32,7 @@ import { JOINED_CHATROOM, LEFT_GROUP, LOGOUT, + OPEN_MODAL, REPLACE_MODAL } from '@utils/events.js' import { imageUpload } from '@utils/image.js' @@ -371,8 +372,8 @@ export default (sbp('sbp/selectors/register', { return } - sbp('okTurtles.events/off', CONTRACT_HAS_RECEIVED_KEYS, eventHandler) - sbp('okTurtles.events/off', LOGOUT, logoutHandler) + removeEventHandler() + removeLogoutHandler() // The event handler recursively calls this same selector // A different path should be taken, since te event handler // should be called after the key request has been answered @@ -382,13 +383,13 @@ export default (sbp('sbp/selectors/register', { }) } const logoutHandler = () => { - sbp('okTurtles.events/off', CONTRACT_HAS_RECEIVED_KEYS, eventHandler) + removeEventHandler() } // The event handler is configured before sending the request // to avoid race conditions - sbp('okTurtles.events/once', LOGOUT, logoutHandler) - sbp('okTurtles.events/on', CONTRACT_HAS_RECEIVED_KEYS, eventHandler) + const removeLogoutHandler = sbp('okTurtles.events/once', LOGOUT, logoutHandler) + const removeEventHandler = sbp('okTurtles.events/on', CONTRACT_HAS_RECEIVED_KEYS, eventHandler) } // !sendKeyRequest && !(hasSecretKeys && !pendingKeyShares) && !(!hasSecretKeys && !pendingKeyShares) && !pendingKeyShares @@ -466,7 +467,7 @@ export default (sbp('sbp/selectors/register', { contractID: params.contractID, contractName: 'gi.contracts/group', data: [encryptedOutgoingData(params.contractID, CEKid, { - foreignKey: `sp:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, + foreignKey: `shelter:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, id: userCSKid, data: userCSKdata, permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'], @@ -504,7 +505,8 @@ export default (sbp('sbp/selectors/register', { } } - sbp('okTurtles.events/emit', JOINED_GROUP, { identityContractID: userID, contractID: params.contractID }) + await sbp('chelonia/contract/wait', params.contractID) + sbp('okTurtles.events/emit', JOINED_GROUP, { identityContractID: userID, groupContractID: params.contractID }) // We don't have the secret keys and we're not waiting for OP_KEY_SHARE // This means that we've been removed from the group } else if (!hasSecretKeys && !pendingKeyShares) { @@ -534,7 +536,7 @@ export default (sbp('sbp/selectors/register', { }) }, 'gi.actions/group/joinWithInviteSecret': async function (groupId: string, secret: string) { - const identityContractID = sbp('chelonia/rootState').loggedIn.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn.identityContractID // This action (`joinWithInviteSecret`) can get invoked while there are // events being processed in the group or identity contracts. This can cause @@ -611,14 +613,14 @@ export default (sbp('sbp/selectors/register', { const cskId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'csk') const csk = { id: cskId, - foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('csk')}`, + foreignKey: `shelter:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('csk')}`, data: contractState._vm.authorizedKeys[cskId].data } const cekId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'cek') const cek = { id: cekId, - foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('cek')}`, + foreignKey: `shelter:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('cek')}`, data: contractState._vm.authorizedKeys[cekId].data } @@ -712,25 +714,36 @@ export default (sbp('sbp/selectors/register', { }) }), 'gi.actions/group/addAndJoinChatRoom': async function (params: GIActionParams) { - const message = await sbp('gi.actions/group/addChatRoom', { - ...omit(params, ['options', 'hooks']), - hooks: { - prepublish: params.hooks?.prepublish, - postpublish: null - } - }) + // Retain needed for the sync placed later to be guaranteed to work + await sbp('chelonia/contract/retain', params.contractID, { ephemeral: true }) + try { + const message = await sbp('gi.actions/group/addChatRoom', { + ...omit(params, ['options', 'hooks']), + hooks: { + prepublish: params.hooks?.prepublish, + postpublish: null + } + }) - const chatRoomID = message.contractID() + const chatRoomID = message.contractID() - await sbp('gi.actions/group/joinChatRoom', { - ...omit(params, ['options', 'data', 'hooks']), - data: { chatRoomID }, - hooks: { - postpublish: params.hooks?.postpublish - } - }) + // Sync needed here to avoid "Cannot join a chatroom which isn't part of + // the group" error + await sbp('chelonia/contract/sync', params.contractID) + await sbp('gi.actions/group/joinChatRoom', { + ...omit(params, ['options', 'data', 'hooks']), + data: { chatRoomID }, + hooks: { + postpublish: params.hooks?.postpublish + } + }) - return chatRoomID + return chatRoomID + } finally { + sbp('chelonia/contract/release', params.contractID, { ephemeral: true }).catch(e => + console.error('[gi.actions/group/addAndJoinChatRoom] Error releasing group chatroom', e) + ) + } }, ...encryptedAction('gi.actions/group/renameChatRoom', L('Failed to rename chat channel.'), async function (sendMessage, params) { await sbp('gi.actions/chatroom/rename', { @@ -873,8 +886,8 @@ export default (sbp('sbp/selectors/register', { // inside of the exception handler :-( } }, - 'gi.actions/group/notifyProposalStateInGeneralChatRoom': function ({ groupID, proposal }: { groupID: string, proposal: Object }) { - const { generalChatRoomId } = sbp('chelonia/rootState')[groupID] + 'gi.actions/group/notifyProposalStateInGeneralChatRoom': async function ({ groupID, proposal }: { groupID: string, proposal: Object }) { + const { generalChatRoomId } = await sbp('chelonia/contract/state', groupID) return sbp('gi.actions/chatroom/addMessage', { contractID: generalChatRoomId, data: { type: MESSAGE_TYPES.INTERACTIVE, proposal } @@ -920,6 +933,15 @@ export default (sbp('sbp/selectors/register', { sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails') } }, + 'gi.actions/group/checkAndSeeProposal': function ({ data }: GIActionParams) { + const openProposalIds = Object.keys(sbp('state/vuex/getters').currentGroupState.proposals || {}) + + if (openProposalIds.includes(data.proposalHash)) { + sbp('controller/router').push({ path: '/dashboard#proposals' }) + } else { + sbp('okTurtles.events/emit', OPEN_MODAL, 'PropositionsAllModal', { targetProposal: data.proposalHash }) + } + }, 'gi.actions/group/fixAnyoneCanJoinLink': function ({ contractID }) { // Queue ensures that the update happens as atomically as possible return sbp('chelonia/queueInvocation', `${contractID}-FIX-ANYONE-CAN-JOIN`, async () => { @@ -986,7 +1008,7 @@ export default (sbp('sbp/selectors/register', { }, ...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.'), async (sendMessage, params) => { const state = await sbp('chelonia/contract/state', params.contractID) - const memberID = params.data.memberID || sbp('chelonia/rootState').loggedIn.identityContractID + const memberID = params.data.memberID || sbp('state/vuex/state').loggedIn.identityContractID const joinedHeight = state.chatRooms[params.data.chatRoomID].members[memberID].joinedHeight // For more efficient and correct processing, augment the leaveChatRoom diff --git a/frontend/controller/actions/identity-kv.js b/frontend/controller/actions/identity-kv.js index 0526701dc9..eeceae0d32 100644 --- a/frontend/controller/actions/identity-kv.js +++ b/frontend/controller/actions/identity-kv.js @@ -1,12 +1,15 @@ 'use strict' import sbp from '@sbp/sbp' import { KV_KEYS } from '~/frontend/utils/constants.js' -import { KV_QUEUE, ONLINE } from '~/frontend/utils/events.js' +import { KV_QUEUE, NEW_PREFERENCES, NEW_UNREAD_MESSAGES, ONLINE } from '~/frontend/utils/events.js' import { isExpired } from '@model/notifications/utils.js' const initNotificationStatus = (data = {}) => ({ ...data, read: false }) sbp('okTurtles.events/on', ONLINE, () => { + if (!sbp('state/vuex/state').loggedIn?.identityContractID) { + return + } sbp('gi.actions/identity/kv/load').catch(e => { console.error("Error from 'gi.actions/identity/kv/load' after reestablished connection:", e) }) @@ -25,14 +28,14 @@ export default (sbp('sbp/selectors/register', { // Using 'chelonia/rootState' here as 'state/vuex/state' is not available // in the SW, and because, even without a SW, 'loggedIn' is not yet there // in Vuex state when logging in - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to fetch chatroom unreadMessages without an active session') } return (await sbp('chelonia/kv/get', identityContractID, KV_KEYS.UNREAD_MESSAGES))?.data || {} }, 'gi.actions/identity/kv/saveChatRoomUnreadMessages': ({ data, onconflict }: { data: Object, onconflict?: Function }) => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to update chatroom unreadMessages without an active session') } @@ -51,8 +54,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/kv/loadChatRoomUnreadMessages': () => { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const currentChatRoomUnreadMessages = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages') - // TODO: Can't use state/vuex/commit - sbp('state/vuex/commit', 'setUnreadMessages', currentChatRoomUnreadMessages) + sbp('okTurtles.events/emit', NEW_UNREAD_MESSAGES, currentChatRoomUnreadMessages) }) }, 'gi.actions/identity/kv/initChatRoomUnreadMessages': ({ contractID, messageHash, createdHeight }: { @@ -170,14 +172,14 @@ export default (sbp('sbp/selectors/register', { }, // Preferences 'gi.actions/identity/kv/fetchPreferences': async () => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to fetch preferences without an active session') } return (await sbp('chelonia/kv/get', identityContractID, KV_KEYS.PREFERENCES))?.data || {} }, 'gi.actions/identity/kv/savePreferences': ({ data, onconflict }: { data: Object, onconflict?: Function }) => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to update preferences without an active session') } @@ -192,8 +194,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/kv/loadPreferences': () => { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const preferences = await sbp('gi.actions/identity/kv/fetchPreferences') - // TODO: Can't use state/vuex/commit - sbp('state/vuex/commit', 'setPreferences', preferences) + sbp('okTurtles.events/emit', NEW_PREFERENCES, preferences) }) }, 'gi.actions/identity/kv/updateDistributionBannerVisibility': ({ contractID, hidden }: { contractID: string, hidden: boolean }) => { @@ -213,14 +214,14 @@ export default (sbp('sbp/selectors/register', { }, // Notifications 'gi.actions/identity/kv/fetchNotificationStatus': async () => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to fetch notification status without an active session') } return (await sbp('chelonia/kv/get', identityContractID, KV_KEYS.NOTIFICATIONS))?.data || {} }, 'gi.actions/identity/kv/saveNotificationStatus': ({ data, onconflict }: { data: Object, onconflict?: Function }) => { - const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID if (!identityContractID) { throw new Error('Unable to update notification status without an active session') } diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 14091dce9d..75a25b075a 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -11,16 +11,17 @@ import { SETTING_CHELONIA_STATE } from '@model/database.js' import sbp from '@sbp/sbp' import { compressImage, imageUpload, objectURLtoBlob } from '@utils/image.js' import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' -import { KV_QUEUE, LOGIN, LOGOUT } from '~/frontend/utils/events.js' +import { JOINED_CHATROOM, KV_QUEUE, LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedIncomingDataWithRawKey, encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' +import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import type { Key } from '../../../shared/domains/chelonia/crypto.js' import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' -import { encryptedAction, groupContractsByType, syncContractsInOrder } from './utils.js' import { handleFetchResult } from '../utils/misc.js' +import { encryptedAction, groupContractsByType, syncContractsInOrder } from './utils.js' export default (sbp('sbp/selectors/register', { 'gi.actions/identity/create': async function ({ @@ -246,6 +247,10 @@ export default (sbp('sbp/selectors/register', { console.debug('[gi.actions/identity/login] Scheduled call starting', identityContractID) transientSecretKeys = transientSecretKeys.map(k => ({ key: deserializeKey(k.valueOf()), transient: true })) + // If running in a SW, start log capture here + if (typeof WorkerGlobalScope === 'function') { + await sbp('swLogs/startCapture', identityContractID) + } await sbp('chelonia/reset', { ...cheloniaState, loggedIn: { identityContractID } }) await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) @@ -418,15 +423,18 @@ export default (sbp('sbp/selectors/register', { } // Clear the file cache when logging out to preserve privacy sbp('gi.db/filesCache/clear').catch((e) => { console.error('Error clearing file cache', e) }) + // If running inside a SW, clear logs + if (typeof WorkerGlobalScope === 'function') { + // clear stored logs to prevent someone else accessing sensitve data + sbp('swLogs/pauseCapture', { wipeOut: true }).catch((e) => { console.error('Error clearing file cache', e) }) + } sbp('okTurtles.events/emit', LOGOUT) return cheloniaState }, 'gi.actions/identity/addJoinDirectMessageKey': async (contractID, foreignContractID, keyName) => { const keyId = await sbp('chelonia/contract/currentKeyIdByName', foreignContractID, keyName) const CEKid = await sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek') - - const rootState = sbp('state/vuex/state') - const foreignContractState = rootState[foreignContractID] + const foreignContractState = sbp('chelonia/contract/state', foreignContractID) const existingForeignKeys = await sbp('chelonia/contract/foreignKeysByContractID', contractID, foreignContractID) @@ -438,7 +446,7 @@ export default (sbp('sbp/selectors/register', { contractID, contractName: 'gi.contracts/identity', data: [encryptedOutgoingData(contractID, CEKid, { - foreignKey: `sp:${encodeURIComponent(foreignContractID)}?keyName=${encodeURIComponent(keyName)}`, + foreignKey: `shelter:${encodeURIComponent(foreignContractID)}?keyName=${encodeURIComponent(keyName)}`, id: keyId, data: foreignContractState._vm.authorizedKeys[keyId].data, // The OP_ACTION_ENCRYPTED is necessary to let the DM counterparty @@ -453,7 +461,7 @@ export default (sbp('sbp/selectors/register', { }) }, 'gi.actions/identity/shareNewPEK': async (contractID: string, newKeys) => { - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const state = rootState[contractID] // TODO: Also share PEK with DMs await Promise.all(Object.keys(state.groups || {}).filter(groupID => !state.groups[groupID].hasLeft && !!rootState.contracts[groupID]).map(async groupID => { @@ -506,15 +514,12 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/identity/setAttributes', L('Failed to set profile attributes.'), undefined, 'pek'), ...encryptedAction('gi.actions/identity/updateSettings', L('Failed to update profile settings.')), ...encryptedAction('gi.actions/identity/createDirectMessage', L('Failed to create a new direct message channel.'), async function (sendMessage, params) { - const rootState = sbp('state/vuex/state') - // TODO: Can't use rootGetters const rootGetters = sbp('state/vuex/getters') const partnerIDs = params.data.memberIDs .filter(memberID => memberID !== rootGetters.ourIdentityContractId) .map(memberID => rootGetters.ourContactProfilesById[memberID].contractID) - // NOTE: 'rootState.currentGroupId' could be changed while waiting for the sbp functions to be proceeded - // So should save it as a constant variable 'currentGroupId', and use it which can't be changed - const currentGroupId = rootState.currentGroupId + const currentGroupId = params.data.currentGroupId + const identityContractID = rootGetters.ourIdentityContractId const message = await sbp('gi.actions/chatroom/create', { data: { @@ -529,11 +534,11 @@ export default (sbp('sbp/selectors/register', { prepublish: params.hooks?.prepublish, postpublish: null } - }, rootState.loggedIn.identityContractID) + }, identityContractID) // Share the keys to the newly created chatroom with ourselves await sbp('gi.actions/out/shareVolatileKeys', { - contractID: rootState.loggedIn.identityContractID, + contractID: identityContractID, contractName: 'gi.contracts/identity', subjectContractID: message.contractID(), keyIds: '*' @@ -542,16 +547,18 @@ export default (sbp('sbp/selectors/register', { await sbp('gi.actions/chatroom/join', { ...omit(params, ['options', 'contractID', 'data', 'hooks']), contractID: message.contractID(), - data: {} + data: { memberID: [identityContractID, ...partnerIDs] } }) - for (const partnerID of partnerIDs) { - await sbp('gi.actions/chatroom/join', { - ...omit(params, ['options', 'contractID', 'data', 'hooks']), - contractID: message.contractID(), - data: { memberID: partnerID } - }) + const switchChannelAfterJoined = (contractID: string) => { + if (contractID === message.contractID()) { + if (sbp('chelonia/contract/state', message.contractID())?.members?.[identityContractID]) { + sbp('okTurtles.events/emit', JOINED_CHATROOM, { identityContractID, groupContractID: currentGroupId, chatRoomID: message.contractID() }) + sbp('okTurtles.events/off', EVENT_HANDLED, switchChannelAfterJoined) + } + } } + sbp('okTurtles.events/on', EVENT_HANDLED, switchChannelAfterJoined) await sendMessage({ ...omit(params, ['options', 'data', 'action', 'hooks']), @@ -591,6 +598,8 @@ export default (sbp('sbp/selectors/register', { hooks }) } + + return message.contractID() }), ...encryptedAction('gi.actions/identity/joinDirectMessage', L('Failed to join a direct message.')), ...encryptedAction('gi.actions/identity/joinGroup', L('Failed to join a group.')), diff --git a/frontend/controller/actions/index.js b/frontend/controller/actions/index.js index ce0b647fae..5a13343e04 100644 --- a/frontend/controller/actions/index.js +++ b/frontend/controller/actions/index.js @@ -91,8 +91,7 @@ sbp('sbp/selectors/register', { keysToRotate: string[] | '*' | 'pending', shareNewKeysSelector?: string ) => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + const state = sbp('chelonia/contract/state', contractID) if (!state) { throw new Error(`[gi.actions/out/rotateKeys] Cannot rotate keys for ${contractID}: No state exists`) diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 24e1f0d360..ce789660c7 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -67,16 +67,24 @@ export const encryptedAction = ( // are written const finished = enqueueDeferredPromise('encrypted-action') + let retainFailed = false try { // Writing to a contract requires being subscribed to it // Since we're only interested in writing and we don't know whether // we're subscribed or should be, we use an ephemeral retain here that // is undone at the end in a finally block. - await sbp('chelonia/contract/retain', contractID, { ephemeral: true }) + await sbp('chelonia/contract/retain', contractID, { ephemeral: true }).catch(e => { + // We use `retainFailed` because the `finally` block should only + // release when `retain` succeeded. Moving the `retain` call outside + // of the `try` block would have the same effect but would require + // duplicating the error handler. + retainFailed = true + throw e + }) const state = { [contractID]: await sbp('chelonia/latestContractState', contractID) } - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') // Default signingContractID is the current contract const signingContractID = params.signingContractID || contractID @@ -141,7 +149,9 @@ export const encryptedAction = ( throw new GIErrorUIRuntimeError(userFacingErrStr, { cause: e }) } finally { finished() - await sbp('chelonia/contract/release', contractID, { ephemeral: true }) + if (!retainFailed) { + await sbp('chelonia/contract/release', contractID, { ephemeral: true }) + } } } } @@ -182,7 +192,7 @@ export const encryptedNotification = ( const state = { [contractID]: await sbp('chelonia/latestContractState', contractID) } - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') // Default signingContractID is the current contract const signingContractID = params.signingContractID || contractID diff --git a/frontend/controller/app/chatroom.js b/frontend/controller/app/chatroom.js index 43b12edf58..79c0c995a9 100644 --- a/frontend/controller/app/chatroom.js +++ b/frontend/controller/app/chatroom.js @@ -1,5 +1,5 @@ import sbp from '@sbp/sbp' -import { DELETED_CHATROOM, JOINED_CHATROOM, LEFT_CHATROOM } from '@utils/events.js' +import { DELETED_CHATROOM, JOINED_CHATROOM, LEFT_CHATROOM, NEW_CHATROOM_UNREAD_POSITION } from '@utils/events.js' const switchCurrentChatRoomHandler = ({ identityContractID, groupContractID, chatRoomID }) => { const rootState = sbp('state/vuex/state') @@ -10,6 +10,7 @@ const switchCurrentChatRoomHandler = ({ identityContractID, groupContractID, cha } } +// handle incoming chatroom-related events that are sent from the service worker sbp('okTurtles.events/on', JOINED_CHATROOM, ({ identityContractID, groupContractID, chatRoomID }) => { const rootState = sbp('state/vuex/state') if (rootState.loggedIn?.identityContractID !== identityContractID) return @@ -31,7 +32,7 @@ sbp('okTurtles.events/on', JOINED_CHATROOM, ({ identityContractID, groupContract console.warn('[JOINED_CHATROOM] Given up on setCurrentChatRoomId after 5 attempts', { identityContractID, groupContractID, chatRoomID }) return } - setTimeout(setCurrentChatRoomId, 5 + 5 * attemptCount) + setTimeout(setCurrentChatRoomId, 5 * Math.pow(1.75, attemptCount)) } else { sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: groupContractID, chatRoomID }) } @@ -41,5 +42,12 @@ sbp('okTurtles.events/on', JOINED_CHATROOM, ({ identityContractID, groupContract }) sbp('okTurtles.events/on', LEFT_CHATROOM, switchCurrentChatRoomHandler) sbp('okTurtles.events/on', DELETED_CHATROOM, switchCurrentChatRoomHandler) +sbp('okTurtles.events/on', NEW_CHATROOM_UNREAD_POSITION, ({ chatRoomID, messageHash }) => { + if (messageHash) { + sbp('state/vuex/commit', 'setChatRoomScrollPosition', { chatRoomID, messageHash }) + } else { + sbp('state/vuex/commit', 'deleteChatRoomScrollPosition', { chatRoomID }) + } +}) export default ([]: string[]) diff --git a/frontend/controller/app/group.js b/frontend/controller/app/group.js index 75a8612726..e2c26898b5 100644 --- a/frontend/controller/app/group.js +++ b/frontend/controller/app/group.js @@ -6,11 +6,12 @@ import { MAX_GROUP_MEMBER_COUNT } from '@model/contracts/shared/constants.js' import sbp from '@sbp/sbp' -import { JOINED_GROUP, LEFT_GROUP, OPEN_MODAL, REPLACE_MODAL, SWITCH_GROUP } from '@utils/events.js' +import { JOINED_GROUP, LEFT_GROUP, NEW_LAST_LOGGED_IN, OPEN_MODAL, REPLACE_MODAL, SWITCH_GROUP } from '@utils/events.js' import ALLOWED_URLS from '@view-utils/allowedUrls.js' import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' import type { GIActionParams } from '../actions/types.js' +// handle incoming group-related events that are sent from the service worker sbp('okTurtles.events/on', JOINED_GROUP, ({ identityContractID, groupContractID }) => { const rootState = sbp('state/vuex/state') if (rootState.loggedIn?.identityContractID !== identityContractID) return @@ -46,6 +47,10 @@ sbp('okTurtles.events/on', LEFT_GROUP, ({ identityContractID, groupContractID }) } }) +sbp('okTurtles.events/on', NEW_LAST_LOGGED_IN, ([contractID, data]) => { + sbp('state/vuex/commit', 'setLastLoggedIn', [contractID, data]) +}) + export default (sbp('sbp/selectors/register', { 'gi.app/group/createAndSwitch': async function (params: GIActionParams) { const contractID = await sbp('gi.actions/group/create', params) @@ -68,6 +73,9 @@ export default (sbp('sbp/selectors/register', { }, 'gi.app/group/addAndJoinChatRoom': async function (params: GIActionParams) { const chatRoomID = await sbp('gi.actions/group/addAndJoinChatRoom', params) + // For an explanation about 'setPendingChatRoomId', see DMMixin.js + // TL;DR: This is an intermediary state to avoid untimely navigation before + // the contract state is available. sbp('state/vuex/commit', 'setPendingChatRoomId', { chatRoomID, groupID: params.contractID }) return chatRoomID }, diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index 493e3e7944..b03bb5d0fc 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -4,8 +4,9 @@ import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js' import { cloneDeep } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' import Vue from 'vue' -import { LOGIN, LOGIN_COMPLETE, LOGIN_ERROR } from '~/frontend/utils/events.js' +import { LOGIN, LOGIN_COMPLETE, LOGIN_ERROR, NEW_PREFERENCES, NEW_UNREAD_MESSAGES } from '~/frontend/utils/events.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { boxKeyPair, buildRegisterSaltRequest, buildUpdateSaltRequestEa, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, serializeKey } from '../../../shared/domains/chelonia/crypto.js' @@ -55,96 +56,107 @@ sbp('okTurtles.events/on', LOGIN, async ({ identityContractID, encryptionParams, // or new Vuex state: any Chelonia-specific state will be set directly from // `cheloniaState` and any exisiting contract state in `state` or `vuexState` // will be discarded. - try { - const vuexState = sbp('state/vuex/state') - if (vuexState.loggedIn && vuexState.loggedIn.identityContractID !== identityContractID) { + await sbp('okTurtles.eventQueue/queueEvent', EVENT_HANDLED, async () => { + try { + const vuexState = sbp('state/vuex/state') + if (vuexState.loggedIn && vuexState.loggedIn.identityContractID !== identityContractID) { // This shouldn't happen. It means that we received a LOGIN event but // there's an active session for a different user. If this happens, it // means that there's buggy login logic that should be reported and fixed - console.error('Received login event during active session', { receivedIdentityContractID: identityContractID, existingIdentityContractID: vuexState.loggedIn.identityContractID }) - throw new Error('Received login event but there already is an active session') - } - const cheloniaState = cloneDeep(await sbp('chelonia/rootState')) - // If `state` is set, process it and replace Vuex state with it - if (state) { + console.error('Received login event during active session', { receivedIdentityContractID: identityContractID, existingIdentityContractID: vuexState.loggedIn.identityContractID }) + throw new Error('Received login event but there already is an active session') + } + const cheloniaState = cloneDeep(await sbp('chelonia/rootState')) + // If `state` is set, process it and replace Vuex state with it + if (state) { // Exclude contracts from the state - if (state.contracts) { - Object.keys(state.contracts).forEach(k => { + if (state.contracts) { + Object.keys(state.contracts).forEach(k => { // Vue.delete not needed as the entire object will replace the state - delete state[k] - }) - } - // Augment state with Chelonia state - Object.keys(cheloniaState.contracts).forEach(k => { - if (cheloniaState[k]) { + delete state[k] + }) + } + // Augment state with Chelonia state + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { // Vue.set not needed as the entire object will replace the state - state[k] = cheloniaState[k] + state[k] = cheloniaState[k] + } + }) + state.contracts = cheloniaState.contracts + if (cheloniaState.namespaceLookups) { + state.namespaceLookups = cheloniaState.namespaceLookups } - }) - state.contracts = cheloniaState.contracts - if (cheloniaState.namespaceLookups) { - state.namespaceLookups = cheloniaState.namespaceLookups - } - // End exclude contracts - sbp('state/vuex/postUpgradeVerification', state) - sbp('state/vuex/replace', state) - } else { + // End exclude contracts + sbp('state/vuex/postUpgradeVerification', state) + sbp('state/vuex/replace', state) + } else { // Else, if `state` was not given, just sync add contracts from Chelonia // to the current Vuex state - const state = vuexState - // Exclude contracts from the state - if (state.contracts) { - Object.keys(state.contracts).forEach(k => { - Vue.delete(state, k) + const state = vuexState + // Exclude contracts from the state + if (state.contracts) { + Object.keys(state.contracts).forEach(k => { + Vue.delete(state, k) + }) + } + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { + Vue.set(state, k, cheloniaState[k]) + } }) - } - Object.keys(cheloniaState.contracts).forEach(k => { - if (cheloniaState[k]) { - Vue.set(state, k, cheloniaState[k]) + Vue.set(state, 'contracts', cheloniaState.contracts) + if (cheloniaState.namespaceLookups) { + Vue.set(state, 'namespaceLookups', cheloniaState.namespaceLookups) } - }) - Vue.set(state, 'contracts', cheloniaState.contracts) - if (cheloniaState.namespaceLookups) { - Vue.set(state, 'namespaceLookups', cheloniaState.namespaceLookups) + // End exclude contracts + sbp('state/vuex/postUpgradeVerification', state) } - // End exclude contracts - sbp('state/vuex/postUpgradeVerification', state) - } - if (encryptionParams) { - sbp('state/vuex/commit', 'login', { identityContractID, encryptionParams }) - } + if (encryptionParams) { + sbp('state/vuex/commit', 'login', { identityContractID, encryptionParams }) + } - // NOTE: users could notice that they leave the group by someone - // else when they log in - const currentState = sbp('state/vuex/state') - if (!currentState.currentGroupId) { - const gId = Object.keys(currentState.contracts) - .find(cID => currentState[identityContractID].groups[cID] && !currentState[identityContractID].groups[cID].hasLeft) + // NOTE: users could notice that they leave the group by someone + // else when they log in + const currentState = sbp('state/vuex/state') + if (!currentState.currentGroupId) { + const gId = Object.keys(currentState.contracts) + .find(cID => currentState[identityContractID].groups[cID] && !currentState[identityContractID].groups[cID].hasLeft) - if (gId) { - sbp('gi.app/group/switch', gId) + if (gId) { + sbp('gi.app/group/switch', gId) + } } + + // Whenever there's an active session, the encrypted save state should be + // removed, as it is only used for recovering the state when logging in + sbp('gi.db/settings/deleteEncrypted', identityContractID).catch(e => { + console.error('Error deleting encrypted settings after login') + }) + + /* Commented out as persistentActions are not being used + // TODO: [SW] It may make more sense to load persistent actions in + // actions in the SW instead of on each tab + const databaseKey = `chelonia/persistentActions/${identityContractID}` + sbp('chelonia.persistentActions/configure', { databaseKey }) + await sbp('chelonia.persistentActions/load') + */ + + sbp('okTurtles.events/emit', LOGIN_COMPLETE, { identityContractID }) + } catch (e) { + sbp('okTurtles.events/emit', LOGIN_ERROR, { identityContractID, error: e }) } + }) +}) - // Whenever there's an active session, the encrypted save state should be - // removed, as it is only used for recovering the state when logging in - sbp('gi.db/settings/deleteEncrypted', identityContractID).catch(e => { - console.error('Error deleting encrypted settings after login') - }) +// handle incoming identity-related events that are sent from the service worker +sbp('okTurtles.events/on', NEW_UNREAD_MESSAGES, (currentChatRoomUnreadMessages) => { + sbp('state/vuex/commit', 'setUnreadMessages', currentChatRoomUnreadMessages) +}) - /* Commented out as persistentActions are not being used - // TODO: [SW] It may make more sense to load persistent actions in - // actions in the SW instead of on each tab - const databaseKey = `chelonia/persistentActions/${identityContractID}` - sbp('chelonia.persistentActions/configure', { databaseKey }) - await sbp('chelonia.persistentActions/load') - */ - - sbp('okTurtles.events/emit', LOGIN_COMPLETE, { identityContractID }) - } catch (e) { - sbp('okTurtles.events/emit', LOGIN_ERROR, { identityContractID, error: e }) - } +sbp('okTurtles.events/on', NEW_PREFERENCES, (preferences) => { + sbp('state/vuex/commit', 'setPreferences', preferences) }) /* Commented out as persistentActions are not being used @@ -325,7 +337,7 @@ export default (sbp('sbp/selectors/register', { } try { - sbp('appLogs/startCapture', identityContractID) + await sbp('appLogs/startCapture', identityContractID) const { state, cheloniaState, encryptionParams } = await loadState(identityContractID, password) let loginCompleteHandler, loginErrorHandler @@ -335,7 +347,7 @@ export default (sbp('sbp/selectors/register', { // complete const loginCompletePromise = new Promise((resolve, reject) => { const loginCompleteHandler = ({ identityContractID: id }) => { - sbp('okTurtles.events/off', LOGIN_ERROR, loginErrorHandler) + removeLoginErrorHandler() if (id === identityContractID) { // Before the promise resolves, we need to save the state // by calling 'state/vuex/save' to ensure that refreshing the page @@ -346,7 +358,7 @@ export default (sbp('sbp/selectors/register', { } } const loginErrorHandler = ({ identityContractID: id, error }) => { - sbp('okTurtles.events/off', LOGIN_COMPLETE, loginCompleteHandler) + removeLoginCompleteHandler() if (id === identityContractID) { reject(error) } else { @@ -354,8 +366,8 @@ export default (sbp('sbp/selectors/register', { } } - sbp('okTurtles.events/once', LOGIN_COMPLETE, loginCompleteHandler) - sbp('okTurtles.events/once', LOGIN_ERROR, loginErrorHandler) + const removeLoginCompleteHandler = sbp('okTurtles.events/once', LOGIN_COMPLETE, loginCompleteHandler) + const removeLoginErrorHandler = sbp('okTurtles.events/once', LOGIN_ERROR, loginErrorHandler) }) // Are we logging in and setting up a fresh session or loading an @@ -369,6 +381,19 @@ export default (sbp('sbp/selectors/register', { // a new Vuex state to replace their state with. await sbp('gi.actions/identity/login', { identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys: transientSecretKeys.map(k => new Secret(serializeKey(k, true))), oldKeysAnchorCid }) } else { + try { + await sbp('chelonia/contract/sync', identityContractID) + } catch (e) { + // To make it easier to test things during development, if the + // identity contract no longer exists, we automatically log out + // If we're in production mode, we show a prompt instead as logging + // out could result in permanent data loss (of the local state). + if (process.env.NODE_ENV !== 'production') { + console.error('Error syncing identity contract, automatically logging out', identityContractID, e) + return sbp('gi.app/identity/_private/logout', state) + } + throw e + } // If an existing session exists, we just emit the LOGIN event // to set the local Vuex state and signal we're ready. sbp('okTurtles.events/emit', LOGIN, { identityContractID, state }) @@ -392,7 +417,7 @@ export default (sbp('sbp/selectors/register', { const result = await sbp('gi.ui/prompt', promptOptions) if (!result) { - return sbp('gi.app/identity/logout') + return sbp('gi.app/identity/_private/logout', state) } else { sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: e }) throw e @@ -421,9 +446,9 @@ export default (sbp('sbp/selectors/register', { // Unlike the login function, the wrapper for logging out is used using a // dedicated selector to allow it to be called from the login selector (if // error occurs) - 'gi.app/identity/_private/logout': async function () { + 'gi.app/identity/_private/logout': async function (errorState: ?Object) { try { - const state = cloneDeep(sbp('state/vuex/state')) + const state = errorState || cloneDeep(sbp('state/vuex/state')) if (!state.loggedIn) return const cheloniaState = await sbp('gi.actions/identity/logout') @@ -437,7 +462,7 @@ export default (sbp('sbp/selectors/register', { await sbp('state/vuex/save', true, state) await sbp('gi.db/settings/deleteStateEncryptionKey', encryptionParams) - sbp('appLogs/pauseCapture', { wipeOut: true }) // clear stored logs to prevent someone else accessing sensitve data + await sbp('appLogs/pauseCapture', { wipeOut: true }) // clear stored logs to prevent someone else accessing sensitve data } } catch (e) { console.error(`${e.name} during logout: ${e.message}`, e) diff --git a/frontend/controller/namespace.js b/frontend/controller/namespace.js index 3d147b5670..33417a9fa5 100644 --- a/frontend/controller/namespace.js +++ b/frontend/controller/namespace.js @@ -1,17 +1,16 @@ 'use strict' import sbp from '@sbp/sbp' -import Vue from 'vue' // NOTE: prefix groups with `group/` and users with `user/` ? sbp('sbp/selectors/register', { 'namespace/lookupCached': (name: string) => { const cache = sbp('state/vuex/state').namespaceLookups - return cache[name] ?? null + return cache?.[name] ?? null }, 'namespace/lookupReverseCached': (id: string) => { const cache = sbp('state/vuex/state').reverseNamespaceLookups - return cache[id] ?? null + return cache?.[id] ?? null }, 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { if (!skipCache) { @@ -23,23 +22,6 @@ sbp('sbp/selectors/register', { return Promise.resolve(cached) } } - return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => { - if (!r.ok) { - console.warn(`namespace/lookup: ${r.status} for ${name}`) - if (r.status !== 404) { - throw new Error(`${r.status}: ${r.statusText}`) - } - return null - } - return r['text']() - }).then(value => { - if (value !== null) { - const cache = sbp('state/vuex/state').namespaceLookups - const reverseCache = sbp('state/vuex/state').reverseNamespaceLookups - Vue.set(cache, name, value) - Vue.set(reverseCache, value, name) - } - return value - }) + return sbp('sw-namespace/lookup', name, { skipCache }) } }) diff --git a/frontend/controller/router.js b/frontend/controller/router.js index 3985507213..461621b4a1 100644 --- a/frontend/controller/router.js +++ b/frontend/controller/router.js @@ -33,14 +33,16 @@ Vue.use(Router) */ const homeGuard = { guard: (to, from) => !!store.state.currentGroupId, - redirect: (to, from) => ({ - path: + redirect: (to, from) => { + return ({ + path: // If we haven't accepted the invite OR we haven't clicked 'Awesome' on // the welcome screen, redirect to the '/pending-approval' page store.getters.seenWelcomeScreen ? '/dashboard' : '/pending-approval' - }) + }) + } } const loginGuard = { diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index c728d2bee7..574fa4d42e 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -1,16 +1,22 @@ 'use strict' import sbp from '@sbp/sbp' -import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' -import { REQUEST_TYPE, PUSH_SERVER_ACTION_TYPE, PUBSUB_RECONNECTION_SUCCEEDED, createMessage } from '~/shared/pubsub.js' +import { CAPTURED_LOGS, LOGIN_COMPLETE, NEW_CHATROOM_UNREAD_POSITION, PWA_INSTALLABLE, SET_APP_LOGS_FILTER } from '@utils/events.js' +import isPwa from '@utils/isPwa.js' import { HOURS_MILLIS } from '~/frontend/model/contracts/shared/time.js' -import { PWA_INSTALLABLE } from '@utils/events.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { deserializer, serializer } from '~/shared/serdes/index.js' +import { ONLINE } from '../utils/events.js' const pwa = { deferredInstallPrompt: null, installed: false } +deserializer.register(GIMessage) +deserializer.register(Secret) + // How to provide your own in-app PWA install experience: // https://web.dev/articles/customize-install @@ -34,25 +40,12 @@ sbp('sbp/selectors/register', { } try { - const swRegistration = await navigator.serviceWorker.register('/assets/js/sw-primary.js', { scope: '/' }) - - // This ensures that the 'store-client-id' message is always sent to the - // service worker, even if it's not immediately available. Calling `removeEventListener` is fine because presumably the page performing an - // update is sending this message. However, the real question is whether - // we _need_ to store a client ID (broadcasting should be preferred) and - // for instances where a single client ID is needed, this should be - // done periodically (e.g., to identify an active window). - // TODO: Clarify the use and need for 'store-client-id'. This will become - // clearer once implementing notifications. - if (swRegistration.active) { - swRegistration.active?.postMessage({ type: 'store-client-id' }) - } else { - const handler = () => { - navigator.serviceWorker.removeEventListener('controllerchange', handler, false) - navigator.serviceWorker.controller.postMessage({ type: 'store-client-id' }) - } - navigator.serviceWorker.addEventListener('controllerchange', handler, false) - } + // Using hash (#) is possible, but seems to get reset when the SW restarts + const params = new URLSearchParams([ + ['routerBase', sbp('controller/router').options.base ?? ''], + ['standalone', isPwa() ? '1' : '0'] + ]) + const swRegistration = await navigator.serviceWorker.register(`/assets/js/sw-primary.js?${params}`, { scope: '/' }) // if an active service-worker exists, checks for the updates immediately first and then repeats it every 1hr await swRegistration.update() @@ -72,14 +65,23 @@ sbp('sbp/selectors/register', { navigator.serviceWorker.addEventListener('message', event => { const data = event.data + const silentEmit = sbp('sbp/selectors/fn', 'okTurtles.events/emit') if (typeof data === 'object' && data.type) { switch (data.type) { case 'pong': break - case 'pushsubscriptionchange': { - console.debug('[sw] Received pushsubscriptionchange:', data) - sbp('service-worker/resubscribe-push', data.subscription) + case 'event': { + sbp('okTurtles.events/emit', event.data.subtype, ...deserializer(event.data.data)) + break + } + case 'navigate': { + sbp('controller/router').push({ path: data.path }).catch(console.warn) + break + } + case CAPTURED_LOGS: { + // Emit silently to avoid flooding logs with event emitted entries + silentEmit(CAPTURED_LOGS, ...deserializer(event.data.data)) break } default: @@ -92,128 +94,61 @@ sbp('sbp/selectors/register', { console.error('error setting up service worker:', e) } }, - 'service-worker/setup-push-subscription': async function () { - if (!('serviceWorker' in navigator) || !('Notification' in window)) { return } - - // Get the installed service-worker registration - const registration = await navigator.serviceWorker.ready - - if (!registration) { - console.error('No service-worker registration found!') - return - } - - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - const existingSubscription = await registration.pushManager.getSubscription() - const messageToPushServerIfSocketConnected = (msgPayload) => { - // make sure the websocket client is not in the state of CLOSING, CLOSED before sending a message. - // if it is, attach a event listener to PUBSUB_RECONNECTION_SUCCEEDED sbp event. - // (context: https://github.com/okTurtles/group-income/pull/1770#discussion_r1439005731) - - const readyState = pubsub.socket.readyState - const sendMsg = () => { - pubsub.socket.send(createMessage( - REQUEST_TYPE.PUSH_ACTION, - msgPayload - )) + // We call this when the notification permission changes, to create a push + // subscription and report it to the server. We need to do this outside of the + // service worker because this generally happens after requesting the + // notifications permission, which requires user interaction and can't be + // done form the SW itself. + // In theory, the `PushManager` APIs used are available in the SW and we could + // have this function there. However, most examples perform this outside of the + // SW, and private testing showed that it's more reliable doing it here. + 'service-worker/setup-push-subscription': async function (retryCount?: number) { + await sbp('okTurtles.eventQueue/queueEvent', 'service-worker/setup-push-subscription', async () => { + // Get the installed service-worker registration + const registration = await navigator.serviceWorker.ready + + if (!registration) { + console.error('No service-worker registration found!') + return } - if (readyState === WebSocket.CLOSED || readyState === WebSocket.CLOSING) { - sbp('okTurtles.events/once', PUBSUB_RECONNECTION_SUCCEEDED, () => { - messageToPushServerIfSocketConnected(msgPayload) - }) - } else { - sendMsg() - } - } - - if (existingSubscription) { - // If there is an existing subscription, no need to create a new one. - // But make sure server knows the subscription details too. - messageToPushServerIfSocketConnected({ - action: PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION, - payload: JSON.stringify(existingSubscription.toJSON()) - }) - } else { - return new Promise((resolve) => { - // Generate a new push subscription - sbp('okTurtles.events/once', REQUEST_TYPE.PUSH_ACTION, async ({ data }) => { - const PUBLIC_VAPID_KEY = data - - try { - // 1. Add a new subscription to pushManager using it. - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY) + const permissionState = await registration.pushManager.permissionState({ userVisibleOnly: true }) + + // Safari sometimes incorrectly reports 'prompt' when using + // `registration.pushManager.permissionState`. + const existingSubscription = permissionState === 'granted' || Notification.permission === 'granted' + ? await registration.pushManager.getSubscription().then((subscription) => { + if ( + !subscription || + (subscription.expirationTime != null && + subscription.expirationTime <= Date.now()) + ) { + console.info( + 'Attempting to create a new subscription', + subscription + ) + return sbp('push/getSubscriptionOptions').then(function (options) { + return registration.pushManager.subscribe(options) }) - - // 2. Store the subscription details to the server. (server needs it to send the push notification) - messageToPushServerIfSocketConnected({ - action: PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION, - payload: JSON.stringify(subscription.toJSON()) + } + return subscription + }).catch(e => { + if (!(retryCount > 3) && e?.message === 'WebSocket connection is not open') { + sbp('okTurtles.events/once', ONLINE, () => { + setTimeout(() => sbp('service-worker/setup-push-subscription', (retryCount || 0) + 1), 200) }) - - resolve() - } catch (err) { - console.error('[sw] service-worker/setup-push-subscription failed with the following error: ', err) - resolve() + return } + console.error('[service-worker/setup-push-subscription] Error setting up push subscription', e) + alert(e?.message || 'Error') }) + : null - messageToPushServerIfSocketConnected({ action: PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY }) + await sbp('push/reportExistingSubscription', existingSubscription?.toJSON()).catch(e => { + console.error('[service-worker/setup-push-subscription] Error reporting existing subscription', e) }) - } - }, - 'service-worker/send-push': async function (payload) { - if (!('serviceWorker' in navigator) || !('Notification' in window)) { return } - - if (Notification.permission !== 'granted') { - console.debug('[sw] stopped sending a push-notification data to the server because of the permission not granted.') - return - } - - const swRegistration = await navigator.serviceWorker.ready - - if (!swRegistration) { - console.error('No service-worker registration found!') - return - } - - const pushSubscription = await swRegistration.pushManager.getSubscription() - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - - if (pushSubscription) { - pubsub.socket.send(createMessage( - REQUEST_TYPE.PUSH_ACTION, - { - action: PUSH_SERVER_ACTION_TYPE.SEND_PUSH_NOTIFICATION, - payload: JSON.stringify({ ...payload, endpoint: pushSubscription.endpoint }) - } - )) - } else { - console.error('No existing push subscription found!') - } - }, - 'service-worker/check-push-subscription-ready': async function () { - if (!('serviceWorker' in navigator) || !('Notification' in window)) { return false } - - const swRegistration = await navigator.serviceWorker.ready - if (swRegistration) { - const pushSubscription = await swRegistration.pushManager.getSubscription() - - return !!pushSubscription - } else { return false } - }, - 'service-worker/resubscribe-push': function (subscription) { - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - - pubsub.socket.send(createMessage( - REQUEST_TYPE.PUSH_ACTION, - { - action: PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION, - payload: JSON.stringify(subscription.toJSON()) - } - )) + return true + }) }, 'service-worker/update': async function () { // This function manually checks for the service worker updates and trigger them if there are. @@ -246,20 +181,83 @@ sbp('sbp/selectors/register', { } }) -// helper method +// Events that need to be relayed to the SW +;[LOGIN_COMPLETE, NEW_CHATROOM_UNREAD_POSITION, SET_APP_LOGS_FILTER].forEach((event) => + sbp('okTurtles.events/on', event, (...data) => { + navigator.serviceWorker.controller?.postMessage({ type: 'event', subtype: event, data }) + }) +) -function urlBase64ToUint8Array (base64String) { - // reference: https://gist.github.com/Klerith/80abd742d726dd587f4bd5d6a0ab26b6 - const padding = '='.repeat((4 - (base64String.length % 4)) % 4) - const base64 = (base64String + padding) - .replace(/-/g, '+') - .replace(/_/g, '/') - - const rawData = window.atob(base64) - const outputArray = new Uint8Array(rawData.length) +const swRpc = (() => { + if (!navigator.serviceWorker) { + throw new Error('Missing service worker object') + } + let controller: ?ServiceWorker = navigator.serviceWorker.controller + navigator.serviceWorker.addEventListener('controllerchange', (ev: Event) => { + controller = (navigator.serviceWorker: any).controller + }, false) + + return (...args) => { + return new Promise((resolve, reject) => { + if (!controller) { + reject(new Error('Service worker not ready')) + return + } + const messageChannel = new MessageChannel() + const onmessage = (event: MessageEvent) => { + if (event.data && Array.isArray(event.data)) { + const r = deserializer(event.data[1]) + // $FlowFixMe[incompatible-use] + if (event.data[0] === true) { + resolve(r) + } else { + reject(r) + } + cleanup() + } + } + const onmessageerror = (event: MessageEvent) => { + reject(event.data) + cleanup() + } + const cleanup = () => { + // This can help prevent memory leaks if the GC doesn't clean up once + // the port goes out of scope + messageChannel.port1.removeEventListener('message', onmessage, false) + messageChannel.port1.removeEventListener('messageerror', onmessageerror, false) + messageChannel.port1.close() + } + messageChannel.port1.addEventListener('message', onmessage, false) + messageChannel.port1.addEventListener('messageerror', onmessageerror, false) + messageChannel.port1.start() + const { data, transferables } = serializer(args) + controller.postMessage({ + type: 'sbp', + port: messageChannel.port2, + data + }, [messageChannel.port2, ...transferables]) + }) + } +})() - for (let i = 0; i < rawData.length; i++) { - outputArray[i] = rawData.charCodeAt(i) +sbp('sbp/selectors/register', { + 'gi.actions/*': swRpc +}) +sbp('sbp/selectors/register', { + 'chelonia/*': swRpc +}) +sbp('sbp/selectors/register', { + 'sw-namespace/*': (...args) => { + // Remove the `sw-` prefix from the selector + return swRpc(args[0].slice(3), ...args.slice(1)) } - return outputArray -} +}) +sbp('sbp/selectors/register', { + 'gi.notifications/*': swRpc +}) +sbp('sbp/selectors/register', { + 'swLogs/*': swRpc +}) +sbp('sbp/selectors/register', { + 'push/*': swRpc +}) diff --git a/frontend/controller/serviceworkers/push.js b/frontend/controller/serviceworkers/push.js new file mode 100644 index 0000000000..0af7c66c17 --- /dev/null +++ b/frontend/controller/serviceworkers/push.js @@ -0,0 +1,158 @@ +import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' +import { makeNotification } from '@model/notifications/nativeNotification.js' +import sbp from '@sbp/sbp' +import setupChelonia from '~/frontend/setupChelonia.js' +import { NOTIFICATION_TYPE, PUBSUB_RECONNECTION_SUCCEEDED, PUSH_SERVER_ACTION_TYPE, REQUEST_TYPE, createMessage } from '~/shared/pubsub.js' + +export default (sbp('sbp/selectors/register', { + 'push/getSubscriptionOptions': (() => { + let cachedVapidInformation + return () => { + if ( + cachedVapidInformation && + // Cache the VAPID information for one hour. The server public + // information should change very infrequently, if it changes at all. + (performance.now() - cachedVapidInformation[0]) < 3600e3 + ) { + return cachedVapidInformation[1] + } + + const result = new Promise((resolve, reject) => { + const handler = ({ data }) => { + if (data.type !== PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY) return + sbp('okTurtles.events/off', REQUEST_TYPE.PUSH_ACTION, handler) + clearTimeout(timeoutId) + resolve({ + userVisibleOnly: true, + applicationServerKey: data.data + }) + } + const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + if (!pubsub) reject(new Error('Missing pubsub instance')) + + const readyState = pubsub.socket.readyState + if (readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket connection is not open')) + } + + sbp('okTurtles.events/on', REQUEST_TYPE.PUSH_ACTION, handler) + const timeoutId = setTimeout(() => { + sbp('okTurtles.events/off', REQUEST_TYPE.PUSH_ACTION, handler) + reject(new Error('Timed out requesting VAPID key')) + }, 10e3) + + pubsub.socket.send(createMessage( + REQUEST_TYPE.PUSH_ACTION, + { action: PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY } + )) + }) + result.then((options) => { + cachedVapidInformation = [performance.now(), Promise.resolve(options)] + }) + return result + } + })(), + // This function reports the existing push subscription to the server + // It is called in three scenarios: + // 1. When a push subscription is created (usually right after granting the + // notification permission). This is done from outside of the SW, because + // requesting permissions can't be done from the SW itself and typically + // requires user interaction. + // 2. When re-connecting (or connecting) to the server via WebSocket. This + // keeps push subscriptions paired to WS connections, so that we can + // seamlessly switch from WS to Web Push notifications on disconnection. + // 3. On the 'pushsubscriptionchange' event. This is to let the server know + // to update the existing push subscription and replace it with a new + // one. + 'push/reportExistingSubscription': (() => { + const map = new WeakMap() + // eslint-disable-next-line require-await + return async (subscriptionInfo: Object) => { + const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + if (!pubsub) throw new Error('Missing pubsub instance') + + const readyState = pubsub.socket.readyState + if (readyState !== WebSocket.OPEN) { + throw new Error('WebSocket connection is not open') + } + + const socket = pubsub.socket + const reported = map.get(socket) + map.set(socket, subscriptionInfo) + if (subscriptionInfo?.endpoint && reported !== subscriptionInfo.endpoint) { + // If the subscription has changed, report it to the server + pubsub.socket.send(createMessage( + REQUEST_TYPE.PUSH_ACTION, + { action: PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION, payload: subscriptionInfo } + )) + } else if (!subscriptionInfo && reported) { + // If the subscription has been removed, also report it to the server + pubsub.socket.send(createMessage( + REQUEST_TYPE.PUSH_ACTION, + { action: PUSH_SERVER_ACTION_TYPE.DELETE_SUBSCRIPTION, payload: null } + )) + } + } + })() +}): string[]) + +if (self.registration?.pushManager) { + (() => { + let inProgress = false + sbp('okTurtles.events/on', PUBSUB_RECONNECTION_SUCCEEDED, () => { + if (inProgress) return + inProgress = true + self.registration.pushManager.getSubscription().then((subscription) => + sbp('push/reportExistingSubscription', subscription?.toJSON()) + ).catch((e) => { + console.error('Error reporting subscription on reconnection', e) + }).finally(() => { + inProgress = false + }) + }) + })() +} + +self.addEventListener('push', function (event) { + // PushEvent reference: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent + if (!event.data) return + let data + try { + data = event.data.json() + } catch (e) { + console.error('[push event] Invalid JSON:', e) + return + } + if (data.type === NOTIFICATION_TYPE.ENTRY) { + event.waitUntil(setupChelonia().then(() => { + // We have event data, so we process it + if (data.data) return sbp('chelonia/handleEvent', data.data) + // We just sync the contract if there's no event data. Sync could fail if + // there are no references, hence the catch + return sbp('chelonia/contract/sync', data.contractID).catch(e => { + console.error('[push event] Error syncing', data.contractID, e) + }) + }).catch((e) => { + console.error('Error processing push event', e) + if (data.contractType === 'gi.contracts/chatroom') { + // TODO: Text for this notification + return makeNotification({ title: '@@@err', body: e.message }) + } + })) + } +}, false) + +self.addEventListener('pushsubscriptionchange', function (event) { + // NOTE: Currently there is no specific way to validate if a push-subscription is valid. So it has to be handled in the front-end. + // (reference:https://pushpad.xyz/blog/web-push-how-to-check-if-a-push-endpoint-is-still-valid) + event.waitUntil((async () => { + try { + const subscription = await self.registration.pushManager.subscribe( + event.oldSubscription.options + ) + await sbp('push/reportExistingSubscription', subscription?.toJSON()) + } catch (e) { + console.error('[pushsubscriptionchange] Error resubscribing:', e) + } + })()) +}, false) diff --git a/frontend/controller/serviceworkers/sw-namespace.js b/frontend/controller/serviceworkers/sw-namespace.js new file mode 100644 index 0000000000..246de5b02e --- /dev/null +++ b/frontend/controller/serviceworkers/sw-namespace.js @@ -0,0 +1,51 @@ +'use strict' + +import sbp from '@sbp/sbp' +import { NAMESPACE_REGISTRATION } from '~/frontend/utils/events.js' + +// NOTE: prefix groups with `group/` and users with `user/` ? +sbp('sbp/selectors/register', { + 'namespace/lookupCached': (name: string) => { + const cache = sbp('chelonia/rootState').namespaceLookups + // 'cache' may be undefined when starting up or after calling chelonia/reset + return cache?.[name] ?? null + }, + 'namespace/lookupReverseCached': (id: string) => { + const cache = sbp('chelonia/rootState').reverseNamespaceLookups + return cache?.[id] ?? null + }, + 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { + if (!skipCache) { + const cached = sbp('namespace/lookupCached', name) + if (cached) { + // Wrapping in a Promise to return a consistent type across all execution + // paths (next return is a Promise) + // This way we can call .then() on the result + return Promise.resolve(cached) + } + } + return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => { + if (!r.ok) { + console.warn(`namespace/lookup: ${r.status} for ${name}`) + if (r.status !== 404) { + throw new Error(`${r.status}: ${r.statusText}`) + } + return null + } + return r['text']() + }).then(value => { + if (value !== null) { + const reactiveSet = sbp('chelonia/config').reactiveSet + const rootState = sbp('chelonia/rootState') + if (!rootState.namespaceLookups) reactiveSet(rootState, 'namespaceLookups', Object.create(null)) + if (!rootState.reverseNamespaceLookups) reactiveSet(rootState, 'reverseNamespaceLookups', Object.create(null)) + const cache = rootState.namespaceLookups + const reverseCache = rootState.reverseNamespaceLookups + reactiveSet(cache, name, value) + reactiveSet(reverseCache, value, name) + sbp('okTurtles.events/emit', NAMESPACE_REGISTRATION, { name, value }) + } + return value + }) + } +}) diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index c68a1de449..3c3f9b83d0 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -1,5 +1,36 @@ 'use strict' +import { MESSAGE_RECEIVE, MESSAGE_SEND, PROPOSAL_ARCHIVED } from '@model/contracts/shared/constants.js' +import '@model/swCaptureLogs.js' +import '@sbp/okturtles.data' +import '@sbp/okturtles.eventqueue' +import '@sbp/okturtles.events' +import sbp from '@sbp/sbp' +import '~/frontend/controller/actions/index.js' +import './sw-namespace.js' +import chatroomGetters from '~/frontend/model/chatroom/getters.js' +import getters from '~/frontend/model/getters.js' +import '~/frontend/model/notifications/selectors.js' +import setupChelonia from '~/frontend/setupChelonia.js' +import { KV_KEYS } from '~/frontend/utils/constants.js' +import { CHELONIA_STATE_MODIFIED, LOGIN, LOGIN_ERROR, LOGOUT } from '~/frontend/utils/events.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { CHELONIA_RESET, CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import { deserializer, serializer } from '~/shared/serdes/index.js' +import { + ACCEPTED_GROUP, CAPTURED_LOGS, CHATROOM_USER_STOP_TYPING, + CHATROOM_USER_TYPING, DELETED_CHATROOM, JOINED_CHATROOM, JOINED_GROUP, + KV_EVENT, LEFT_CHATROOM, LEFT_GROUP, NAMESPACE_REGISTRATION, + NEW_CHATROOM_UNREAD_POSITION, NEW_LAST_LOGGED_IN, NEW_PREFERENCES, + NEW_UNREAD_MESSAGES, NOTIFICATION_EMITTED, NOTIFICATION_REMOVED, + NOTIFICATION_STATUS_LOADED, OFFLINE, ONLINE, SERIOUS_ERROR, SWITCH_GROUP +} from '../../utils/events.js' +import './push.js' + +deserializer.register(GIMessage) +deserializer.register(Secret) + // https://serviceworke.rs/message-relay_service-worker_doc.html // https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers // https://jakearchibald.com/2014/using-serviceworker-today/ @@ -7,6 +38,140 @@ // https://frontendian.co/service-workers // https://stackoverflow.com/a/49748437 => https://medium.com/@nekrtemplar/self-destroying-serviceworker-73d62921d717 => https://love2dev.com/blog/how-to-uninstall-a-service-worker/ +const reducer = (o, v) => { o[v] = true; return o } +// Domains for which debug logging won't be enabled. +const domainBlacklist = [ + 'sbp', + 'okTurtles.data' +].reduce(reducer, {}) +// Selectors for which debug logging won't be enabled. +const selectorBlacklist = [ + 'chelonia/db/get', + 'chelonia/db/set', + 'chelonia/rootState', + 'chelonia/haveSecretKey', + 'chelonia/private/enqueuePostSyncOps', + 'chelonia/private/invoke', + 'state/vuex/state', + 'state/vuex/getters', + 'state/vuex/settings', + 'gi.db/settings/save', + 'gi.db/logs/save' +].reduce(reducer, {}) +sbp('sbp/filters/global/add', (domain, selector, data) => { + if (domainBlacklist[domain] || selectorBlacklist[selector]) return + console.debug(`[sbp] ${selector}`, data) +}) + +// This function sets up state keys used in the SW that mirror the corresponding +// Vuex keys +const setupRootState = () => { + const rootState = sbp('chelonia/rootState') + + if (!rootState.chatroom) rootState.chatroom = Object.create(null) + if (!rootState.chatroom.chatRoomScrollPosition) rootState.chatroom.chatRoomScrollPosition = Object.create(null) + if (!rootState.chatroom.unreadMessages) rootState.chatroom.unreadMessages = Object.create(null) + + if (!rootState.lastLoggedIn) rootState.lastLoggedIn = Object.create(null) + + if (!rootState.notifications) rootState.notifications = Object.create(null) + if (!rootState.notifications.items) rootState.notifications.items = [] + if (!rootState.notifications.status) rootState.notifications.status = Object.create(null) +} + +sbp('okTurtles.events/on', CHELONIA_RESET, setupRootState) + +// These are all of the events that will be forwarded to all open tabs and windows +;[ + CHELONIA_RESET, CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, EVENT_HANDLED, LOGIN, + LOGIN_ERROR, LOGOUT, ACCEPTED_GROUP, CHATROOM_USER_STOP_TYPING, + CHATROOM_USER_TYPING, DELETED_CHATROOM, LEFT_CHATROOM, LEFT_GROUP, + JOINED_CHATROOM, JOINED_GROUP, KV_EVENT, MESSAGE_RECEIVE, MESSAGE_SEND, + NAMESPACE_REGISTRATION, NEW_CHATROOM_UNREAD_POSITION, NEW_LAST_LOGGED_IN, + NEW_PREFERENCES, NEW_UNREAD_MESSAGES, NOTIFICATION_EMITTED, + NOTIFICATION_REMOVED, NOTIFICATION_STATUS_LOADED, OFFLINE, ONLINE, + PROPOSAL_ARCHIVED, SERIOUS_ERROR, SWITCH_GROUP +].forEach(et => { + sbp('okTurtles.events/on', et, (...args) => { + const { data } = serializer(args) + const message = { + type: 'event', + subtype: et, + data + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) + }) +}) + +// Logs are treated especially to avoid spamming logs with event emitted +// entries +sbp('okTurtles.events/on', CAPTURED_LOGS, (...args) => { + const { data } = serializer(args) + const message = { + type: CAPTURED_LOGS, + data + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) +}) + +sbp('sbp/selectors/register', { + 'state/vuex/state': () => sbp('chelonia/rootState'), + 'state/vuex/getters': (() => { + // Singleton lazily generated getters + let obj + return () => { + if (!obj) { + obj = Object.create(null) + Object.defineProperties(obj, Object.fromEntries(Object.entries(getters).map(([getter, fn]: [string, Function]) => { + return [getter, { + get: () => { + const state = sbp('chelonia/rootState') + return fn(state, obj) + } + }] + }))) + Object.defineProperties(obj, Object.fromEntries(Object.entries(chatroomGetters).map(([getter, fn]: [string, Function]) => { + return [getter, { + get: () => { + const state = sbp('chelonia/rootState') + // `state.chatroom` represents the `chatroom` module. For the SW, + // this is defined in `sw-primary.js`. + return fn(state.chatroom || {}, obj, state) + } + }] + }))) + } + + return obj + } + })() +}) + +const x = new URL(self.location) + +sbp('sbp/selectors/register', { + 'controller/router': () => { + return { options: { base: x.searchParams.get('routerBase') } } + } +}) + +sbp('sbp/selectors/register', { + 'appLogs/save': () => sbp('swLogs/save') +}) + +setupRootState() +const setupPromise = setupChelonia() + self.addEventListener('install', function (event) { console.debug('[sw] install') event.waitUntil(self.skipWaiting()) @@ -16,7 +181,7 @@ self.addEventListener('activate', function (event) { console.debug('[sw] activate') // 'clients.claim()' reference: https://web.dev/articles/service-worker-lifecycle#clientsclaim - event.waitUntil(self.clients.claim()) + event.waitUntil(setupPromise.then(() => self.clients.claim())) }) self.addEventListener('fetch', function (event) { @@ -26,7 +191,7 @@ self.addEventListener('fetch', function (event) { // TODO: this doesn't persist data across browser restarts, so try to use // the cache instead, or just localstorage. Investigate whether the service worker // has the ability to access and clear the localstorage periodically. -const store = {} +/* const store = {} const sendMessageToClient = async function (payload) { if (!store.clientId) { console.error('[sw] Cannot send a message to a client, because no client id is found') @@ -38,25 +203,30 @@ const sendMessageToClient = async function (payload) { client.postMessage(payload) } } +*/ self.addEventListener('message', function (event) { - console.debug(`[sw] message from ${event.source.id}. Current store:`, store) + console.debug(`[sw] message from ${event.source.id} of type ${event.data?.type}.`) // const client = await self.clients.get(event.source.id) // const client = await self.clients.get(event.clientId) if (typeof event.data === 'object' && event.data.type) { console.debug('[sw] event received:', event.data) switch (event.data.type) { - case 'set': - store[event.data.key] = event.data.value - break - case 'get': - event.source.postMessage({ - response: store[event.data.key] + case 'sbp': { + // We don't filter the selectors because such a filter would be + // difficult to implement and easy to circumvent. + const port = event.data.port + ;(async () => await sbp(...deserializer(event.data.data)))().then((r) => { + const { data, transferables } = serializer(r) + port.postMessage([true, data], transferables) + }).catch((e) => { + const { data, transferables } = serializer(e) + port.postMessage([false, data], transferables) + }).finally(() => { + port.close() }) break - case 'store-client-id': - store.clientId = event.source.id - break + } case 'ping': event.source.postMessage({ type: 'pong' }) break @@ -71,6 +241,9 @@ self.addEventListener('message', function (event) { clients.forEach(client => client.navigate(client.url)) }) break + case 'event': + sbp('okTurtles.events/emit', event.data.subtype, ...deserializer(event.data.data)) + break default: console.error('[sw] unknown message type:', event.data) break @@ -83,36 +256,73 @@ self.addEventListener('message', function (event) { // Handle clicks on notifications issued via registration.showNotification(). self.addEventListener('notificationclick', event => { console.debug('[sw] Notification clicked:', event.notification) -}) + event.notification.close() -self.addEventListener('push', function (event) { - // PushEvent reference: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then((clientList) => { + clientList.sort((a, b) => { + if (a.focused !== b.focused) { + return a.focused ? -1 : 1 + } + if (a.visibilityState !== b.visibilityState) { + // order is visible, prerender, hidden + if (a.visibilityState === 'visible') return -1 + if (b.visibilityState === 'visible') return 1 + if (a.visibilityState === 'hidden') return 1 + if (b.visibilityState === 'hidden') return -1 + } - if (!(self.Notification && self.Notification.permission === 'granted')) { - console.debug("[sw] received a push notification but aren't displaying it due to the permission not granted") - return - } - - const data = event.data.json() - console.debug('[sw] push received: ', data) + return 0 + }) + if (!clientList.length) { + return self.clients.openWindow(`${sbp('controller/router').options.base}${event.notification.data.path ?? '/'}`) + } + const client = clientList[0] + if (event.notification.data?.path) { + client.postMessage({ + type: 'navigate', + path: event.notification.data.path + }) + } + if (!client.focused) return client.focus() + })) +}) - self.registration.showNotification( - data.title, - { - body: data.body || '', - icon: '/assets/images/pwa-icons/group-income-icon-transparent.svg' +sbp('okTurtles.events/on', KV_EVENT, ({ contractID, key, data }) => { + const rootState = sbp('chelonia/rootState') + const ourIdentityContractID = rootState.loggedIn?.identityContractID + if (contractID !== ourIdentityContractID) return + // the following keys mirror the corresponding keys in Vuex modules in the + // app + switch (key) { + case KV_KEYS.LAST_LOGGED_IN: { + rootState.lastLoggedIn[contractID] = data + break + } + case KV_KEYS.UNREAD_MESSAGES: { + rootState.chatroom.unreadMessages = data + break + } + case KV_KEYS.PREFERENCES: { + rootState.preferences = data + break + } + case KV_KEYS.NOTIFICATIONS: { + rootState.notifications.status = data + break } - ) + default: + return + } + sbp('okTurtles.events/emit', CHELONIA_STATE_MODIFIED) }) -self.addEventListener('pushsubscriptionchange', async function (event) { - // NOTE: Currently there is no specific way to validate if a push-subscription is valid. So it has to be handled in the front-end. - // (reference:https://pushpad.xyz/blog/web-push-how-to-check-if-a-push-endpoint-is-still-valid) - const subscription = await self.registration.pushManger.subscribe(event.oldSubscription.options) - - // Sending the client a message letting it know of the subscription change. - await sendMessageToClient({ - type: 'pushsubscriptionchange', - subscription - }) +sbp('okTurtles.events/on', NEW_CHATROOM_UNREAD_POSITION, ({ chatRoomID, messageHash }) => { + const rootState = sbp('chelonia/rootState') + if (messageHash) { + rootState.chatroom.chatRoomScrollPosition[chatRoomID] = messageHash + } else { + delete rootState.chatroom.chatRoomScrollPosition[chatRoomID] + } + sbp('okTurtles.events/emit', CHELONIA_STATE_MODIFIED) }) diff --git a/frontend/main.js b/frontend/main.js index 32793e9541..cd9306bef6 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -3,6 +3,7 @@ // import SBP stuff before anything else so that domains register themselves before called import { L, LError } from '@common/common.js' import '@model/captureLogs.js' +import { setupNativeNotificationsListeners } from '@model/notifications/nativeNotification.js' import '@sbp/okturtles.data' import '@sbp/okturtles.eventqueue' import '@sbp/okturtles.events' @@ -11,12 +12,10 @@ import ALLOWED_URLS from '@view-utils/allowedUrls.js' import IdleVue from 'idle-vue' import { mapGetters, mapMutations, mapState } from 'vuex' import 'wicg-inert' -import '~/shared/domains/chelonia/chelonia.js' import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.js' import '~/shared/domains/chelonia/localSelectors.js' import { KV_KEYS } from './utils/constants.js' // import '~/shared/domains/chelonia/persistent-actions.js' // Commented out as persistentActions are not being used -import './controller/actions/index.js' import './controller/app/index.js' import './controller/backend.js' import './controller/namespace.js' @@ -24,7 +23,7 @@ import router from './controller/router.js' import './controller/service-worker.js' import { SETTING_CURRENT_USER } from './model/database.js' import store from './model/state.js' -import { KV_EVENT, LOGIN_COMPLETE, LOGIN_ERROR, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SWITCH_GROUP, THEME_CHANGE } from './utils/events.js' +import { KV_EVENT, LOGIN_COMPLETE, LOGIN_ERROR, LOGOUT, NAMESPACE_REGISTRATION, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SERIOUS_ERROR, SWITCH_GROUP, THEME_CHANGE } from './utils/events.js' import AppStyles from './views/components/AppStyles.vue' import BannerGeneral from './views/components/banners/BannerGeneral.vue' import Modal from './views/components/modal/Modal.vue' @@ -39,7 +38,6 @@ import './views/utils/vFocus.js' import Vue from 'vue' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' import './model/notifications/periodicNotifications.js' -import setupChelonia from './setupChelonia.js' import FaviconBadge from './utils/faviconBadge.js' import './utils/touchInteractions.js' import { showNavMixin } from './views/utils/misc.js' @@ -96,20 +94,29 @@ async function startApp () { // Set up event listeners to keep local (Vuex) and Chelonia states in sync sbp('chelonia/externalStateSetup', { stateSelector: 'state/vuex/state', reactiveSet: Vue.set, reactiveDel: Vue.delete }) - // TODO: [SW] The following will be needed to keep namespace registrations - // in sync between the SW and each tab. It is not needed now because everything - // is running in the same context - /* sbp('okTurtles.events/on', NAMESPACE_REGISTRATION, ({ name, value }) => { + // [SW] The following is be needed to keep namespace registrations in sync + // between the SW and each tab. It is not needed if everything is running in + // the same context + sbp('okTurtles.events/on', NAMESPACE_REGISTRATION, ({ name, value }) => { const cache = sbp('state/vuex/state').namespaceLookups + const reverseCache = sbp('state/vuex/state').reverseNamespaceLookups Vue.set(cache, name, value) - }) */ + Vue.set(reverseCache, value, name) + }) + + sbp('okTurtles.events/on', SERIOUS_ERROR, (error) => { + sbp('gi.ui/seriousErrorBanner', error) + if (process.env.CI) { + Promise.reject(error) + } + }) // NOTE: setting 'EXPOSE_SBP' in production will make it easier for users to generate contract // actions that they shouldn't be generating, which can lead to bugs or trigger the automated // ban system. Only enable it if you know what you're doing and don't mind the risk. // IMPORTANT: setting 'window.sbp' must come *after* 'chelonia/configure' so that the Cypress // tests don't attempt to use the contracts before they're ready! - if (process.env.NODE_ENV === 'development' || window.Cypress || process.env.EXPOSE_SBP === 'true') { + if (process.env.NODE_ENV === 'development' || window.Cypress || process.env.EXPOSE_SBP === 'true' || debugParam) { // In development mode this makes the SBP API available in the devtools console. window.sbp = sbp } @@ -145,7 +152,7 @@ async function startApp () { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('Timed out setting up service worker')) - }, 8e3) + }, 16e3) })] ).catch(e => { console.error('[main] Error setting up service worker', e) @@ -153,6 +160,13 @@ async function startApp () { window.location.reload() // try again, sometimes it fixes it throw e }) + // Call `setNotificationEnabled` after the service worker setup, because it + // calls `service-worker/setup-push-subscription`. + if (typeof Notification === 'function') { + sbp('state/vuex/commit', 'setNotificationEnabled', Notification.permission === 'granted') + } + + sbp('okTurtles.data/set', 'API_URL', self.location.origin) /* eslint-disable no-new */ new Vue({ @@ -298,14 +312,16 @@ async function startApp () { // happened (an example where things can happen this quickly is in the // tests). let oldIdentityContractID = null - setupChelonia().then(() => sbp('gi.db/settings/load', SETTING_CURRENT_USER)).then(async (identityContractID) => { + sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(async (identityContractID) => { oldIdentityContractID = identityContractID if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return await sbp('gi.app/identity/login', { identityContractID }) await sbp('chelonia/contract/wait', identityContractID) }).catch(async e => { this.removeLoadingAnimation() - oldIdentityContractID && sbp('appLogs/clearLogs', oldIdentityContractID) // https://github.com/okTurtles/group-income/issues/2194 + oldIdentityContractID && sbp('appLogs/clearLogs', oldIdentityContractID).catch(e => { + console.error('[main] Error clearing logs for old session', oldIdentityContractID, e) + }) // https://github.com/okTurtles/group-income/issues/2194 console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e) await sbp('gi.app/identity/logout') await sbp('gi.ui/prompt', { @@ -314,8 +330,33 @@ async function startApp () { primaryButton: L('Close') }) }).finally(() => { - this.ephemeral.ready = true - this.removeLoadingAnimation() + // Wait for SW to be ready + console.debug('[app] Waiting for SW to be ready') + const sw = ((navigator.serviceWorker: any): ServiceWorkerContainer) + Promise.race([ + sw.ready, + new Promise((resolve, reject) => setTimeout(() => reject(new Error('SW ready timeout')), 10000)) + ]).then(() => { + const onready = () => { + this.ephemeral.ready = true + this.removeLoadingAnimation() + setupNativeNotificationsListeners() + } + if (!sw.controller) { + const listener = (ev: Event) => { + sw.removeEventListener('controllerchange', listener, false) + onready() + } + sw.addEventListener('controllerchange', listener, false) + } else { + onready() + } + }).catch(e => { + console.error('[app] Service worker failed to become ready:', e) + // Fallback behavior + this.removeLoadingAnimation() + alert(L('Error while setting up service worker')) + }) }) }, computed: { diff --git a/frontend/model/captureLogs.js b/frontend/model/captureLogs.js index 4c243cd659..41dc44ed92 100644 --- a/frontend/model/captureLogs.js +++ b/frontend/model/captureLogs.js @@ -1,8 +1,8 @@ import sbp from '@sbp/sbp' -import { CAPTURED_LOGS, SET_APP_LOGS_FILTER } from '~/frontend/utils/events.js' +import { SET_APP_LOGS_FILTER } from '~/frontend/utils/events.js' import { MAX_LOG_ENTRIES } from '~/frontend/utils/constants.js' -import CircularList from '~/shared/CircularList.js' -import { L } from '@common/common.js' +import { createLogger } from './logger.js' +import logServer from './logServer.js' /* - giConsole/[username]/entries - the stored log entries. @@ -10,209 +10,80 @@ import { L } from '@common/common.js' */ const config = { - maxEntries: MAX_LOG_ENTRIES + maxEntries: MAX_LOG_ENTRIES, + source: 'browser' } -const consoleCopy = { ...console } -const loggingLevels = ['debug', 'error', 'info', 'log', 'warn'] -const noop = () => undefined -const originalConsole = console +const originalConsole = self.console // These are initialized in `captureLogsStart()`. -let appLogsFilter: string[] = [] let logger: Object = null let identityContractID: string = '' -// A default storage backend using `localStorage`. -const getItem = (key: string): ?string => localStorage.getItem(`giConsole/${identityContractID}/${key}`) -const removeItem = (key: string): void => localStorage.removeItem(`giConsole/${identityContractID}/${key}`) +// A default storage backend using `sessionStorage`. +const getItem = (key: string): ?string => sessionStorage.getItem(`giConsole/${identityContractID}/${key}`) +const removeItem = (key: string): void => sessionStorage.removeItem(`giConsole/${identityContractID}/${key}`) const setItem = (key: string, value: any): void => { - localStorage.setItem(`giConsole/${identityContractID}/${key}`, typeof value === 'string' ? value : JSON.stringify(value)) + sessionStorage.setItem(`giConsole/${identityContractID}/${key}`, typeof value === 'string' ? value : JSON.stringify(value)) } -function createLogger (config: Object): Object { - const entries = new CircularList(config.maxEntries) - const methods = loggingLevels.reduce( - (acc, name) => { - acc[name] = (...args) => { - originalConsole[name](...args) - captureLogEntry(name, ...args) - } - return acc - }, - {} - ) - return { - entries, - ...methods, - save () { - try { - setItem('entries', this.entries.toArray()) - } catch (error) { - console.error(error) - } - } - } -} - -function captureLogEntry (type, ...args) { - const entry = { - timestamp: new Date().toISOString(), - type, - // Detect when arg is an Error and capture it properly. - // ex: uncaught Vue errors or custom try/catch errors. - msg: args.map((arg) => { - try { - return JSON.parse( - JSON.stringify(arg, (_, v) => { - if (v instanceof Error) { - return { - name: v.name, - message: v.message, - stack: v.stack - } - } - return v - }) - ) - } catch (e) { - return `[captureLogs failed to stringify argument of type '${typeof arg}'. Err: ${e.message}]` - } - }) - } - getLogger().entries.add(entry) - // To avoid infinite loop because we log all selector calls, we run sbp calls - // here in a roundabout way by getting the function to which they're mapped. - // The reason this works is because the entire `sbp` domain is blacklisted - // from being logged in main.js. - sbp('sbp/selectors/fn', 'okTurtles.events/emit')(CAPTURED_LOGS, entry) - sbp('sbp/selectors/fn', 'appLogs/logServer')(type, entry.msg) -} - -function captureLogsStart (userLogged: string) { +async function captureLogsStart (userLogged: string) { identityContractID = userLogged - logger = getLogger() + logger = await createLogger(config, { getItem, removeItem, setItem }) // Save the new config. setItem('config', config) - setAppLogsFilter(sbp('state/vuex/state').settings?.appLogsFilter ?? []) + logger.setAppLogsFilter(sbp('state/vuex/state').settings?.appLogsFilter ?? []) // Subscribe to `appLogsFilter` changes. - sbp('okTurtles.events/on', SET_APP_LOGS_FILTER, setAppLogsFilter) + sbp('okTurtles.events/on', SET_APP_LOGS_FILTER, logger.setAppLogsFilter) // Overwrite the original console. - window.console = consoleCopy + self.console = logger.console // Set a new visit or session - useful to understand logs through time. // NEW_SESSION -> The user opened a new browser or tab. // NEW_VISIT -> The user comes from an ongoing session (refresh or login). const isNewSession = !sessionStorage.getItem('NEW_SESSION') if (isNewSession) { sessionStorage.setItem('NEW_SESSION', '1') } - console.log(isNewSession ? 'NEW_SESSION' : 'NEW_VISIT', 'Starting to capture logs of type:', appLogsFilter) + originalConsole.log(isNewSession ? 'NEW_SESSION' : 'NEW_VISIT', 'Starting to capture logs of type:', logger.appLogsFilter) } -function captureLogsPause ({ wipeOut }: { wipeOut: boolean }): void { - if (wipeOut) { clearLogs() } +async function captureLogsPause ({ wipeOut }: { wipeOut: boolean }): Promise { + if (wipeOut) { await clearLogs() } sbp('okTurtles.events/off', SET_APP_LOGS_FILTER) console.log('captureLogs paused') // Restore original console behavior. - window.console = originalConsole -} - -function clearLogs () { - removeItem('entries') - logger?.entries?.clear() + self.console = originalConsole } -// Util to download all stored logs so far. -function downloadOrShareLogs (actionType: 'share' | 'download', elLink?: HTMLAnchorElement): any { - const filename = 'gi_logs.json.txt' - const mimeType = 'text/plain' - - const blob = new Blob([JSON.stringify({ - // Add instructions in case the user opens the file. - _instructions: 'GROUP INCOME - Application Logs - Attach this file when reporting an issue: https://github.com/okTurtles/group-income/issues', - ua: navigator.userAgent, - logs: getLogger().entries.toArray() - }, undefined, 2)], { type: mimeType }) - - if (actionType === 'download') { - if (!elLink) { return } - - const url = URL.createObjectURL(blob) - elLink.href = url - elLink.download = filename - elLink.click() - setTimeout(() => { - elLink.href = '#' - URL.revokeObjectURL(url) - }, 0) - } else { - return window.navigator.share({ - files: [new File([blob], filename, { type: blob.type })], - title: L('Application Logs') - }) - } +async function clearLogs () { + await logger?.clear() } -function getLogger (): Object { - if (!logger) { - logger = createLogger(config) - const previousEntries = JSON.parse(getItem('entries') ?? '[]') - - // If `maxEntries` is changed in a release, this will discard oldest logs as necessary. - if (config.maxEntries < previousEntries.length) { - previousEntries.splice(0, previousEntries.length - config.maxEntries) - } - // Load the previous entries to sync the in-memory array with the local storage. - if (previousEntries.length) { - logger.entries.addAll(previousEntries) - } - } - return logger -} - -function setAppLogsFilter (filter: Array) { - appLogsFilter = filter - // NOTE: Find a way to capture logs without messing up with log file location. - // console.log() doesnt include stack trace, so when logged, we can't access - // where the log came from (file name), which} difficults debugging if needed. - for (const level of loggingLevels) { - // $FlowFixMe - consoleCopy[level] = appLogsFilter.includes(level) ? logger[level] : noop - } -} - -window.addEventListener('beforeunload', event => sbp('appLogs/save')) - -sbp('sbp/selectors/register', { - 'appLogs/downloadOrShare': downloadOrShareLogs, - 'appLogs/get' () { return getLogger()?.entries?.toArray() ?? [] }, - 'appLogs/save' () { getLogger()?.save() }, +// The reason to use the 'visibilitychange' event over the 'beforeunload' event +// is that the latter is unreliable on mobile. For example, if a tab is set to +// the background and then closed, the 'beforeunload' event may never be fired. +// Furthermore, 'beforeunload' has implications for how the 'bfcache' works. +// See . 'bfcache' or 'Back/forward cache' is +// what enables instant navigation using the browser back and forward buttons. +window.addEventListener('visibilitychange', event => sbp('appLogs/save').catch(e => { + console.error('Error saving logs during visibilitychange event handler', e) +})) + +// Enable logging to the server +logServer(originalConsole) + +export default (sbp('sbp/selectors/register', { + 'appLogs/get' () { return logger?.entries.toArray() ?? [] }, + async 'appLogs/save' () { await logger?.save() }, 'appLogs/pauseCapture': captureLogsPause, 'appLogs/startCapture': captureLogsStart, - 'appLogs/clearLogs': function (userID) { + async 'appLogs/clearLogs' (userID) { const savedID = identityContractID identityContractID = userID - try { clearLogs() } catch {} + try { await clearLogs() } catch {} identityContractID = savedID - }, - // only log to server if we're in development mode and connected over the tunnel (which creates URLs that - // begin with 'https://gi' per Gruntfile.js) - 'appLogs/logServer': process.env.NODE_ENV !== 'development' || !window.location.href.startsWith('https://gi') - ? noop - : function (level, stringifyMe) { - if (level === 'debug') return // comment out to send much more log info - const value = JSON.stringify(stringifyMe) - fetch(`${sbp('okTurtles.data/get', 'API_URL')}/log`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ level, value }) - }).catch(e => { - originalConsole.error(`[captureLogs] '${e.message}' attempting to log [${level}] to server:`, value) - }) - } -}) + } +}): string[]) diff --git a/frontend/model/chatroom/getters.js b/frontend/model/chatroom/getters.js new file mode 100644 index 0000000000..bf58cb2b1d --- /dev/null +++ b/frontend/model/chatroom/getters.js @@ -0,0 +1,201 @@ +'use strict' + +import { merge, union } from '@model/contracts/shared/giLodash.js' +import { MESSAGE_NOTIFY_SETTINGS, CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' + +const getters: { [x: string]: (state: Object, getters: { [x: string]: any }, rootState: Object) => any } = { + currentChatRoomId (state, getters, rootState) { + return state.currentChatRoomIDs[rootState.currentGroupId] || null + }, + currentChatRoomState (state, getters, rootState) { + return rootState[getters.currentChatRoomId] || {} // avoid "undefined" vue errors at inoportune times + }, + chatNotificationSettings (state) { + return Object.assign({ + default: { + messageNotification: MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES, + messageSound: MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES + } + }, state.chatNotificationSettings || {}) + }, + ourUnreadMessages (state) { + return state.unreadMessages || {} + }, + directMessagesByGroup (state, getters, rootState) { + return groupID => { + const currentGroupDirectMessages = {} + + if (!groupID) { + // NOTE: groupID could be null before finish syncing group contracts + return currentGroupDirectMessages + } + + for (const chatRoomID of Object.keys(getters.ourDirectMessages)) { + const chatRoomState = rootState[chatRoomID] + const directMessageSettings = getters.ourDirectMessages[chatRoomID] + const myIdendityId = getters.ourIdentityContractId + + // NOTE: skip DMs whose chatroom contracts are not synced yet + if (!chatRoomState || !chatRoomState.members?.[myIdendityId]) { + continue + } + // NOTE: direct messages should be filtered to the ones which are visible and of active group members + const members = Object.keys(chatRoomState.members) + const isDMToMyself = members.length === 1 && members[0] === myIdendityId + const partners = members + .filter(memberID => memberID !== myIdendityId) + .sort((p1, p2) => { + const p1JoinedDate = new Date(chatRoomState.members[p1].joinedDate).getTime() + const p2JoinedDate = new Date(chatRoomState.members[p2].joinedDate).getTime() + return p1JoinedDate - p2JoinedDate + }) + const hasActiveMember = partners.some(memberID => Object.keys(getters.profilesByGroup(groupID)).includes(memberID)) + if (directMessageSettings.visible && (isDMToMyself || hasActiveMember)) { + // NOTE: lastJoinedParter is chatroom member who has joined the chatroom for the last time. + // His profile picture can be used as the picture of the direct message + // possibly with the badge of the number of partners. + const lastJoinedPartner = isDMToMyself ? myIdendityId : partners[partners.length - 1] + const lastMsgTimeStamp = chatRoomState.messages?.length > 0 + ? new Date(chatRoomState.messages[chatRoomState.messages.length - 1].datetime).getTime() + : 0 + + currentGroupDirectMessages[chatRoomID] = { + ...directMessageSettings, + members, + partners: partners.map(memberID => ({ + contractID: memberID, + username: getters.usernameFromID(memberID), + displayName: getters.userDisplayNameFromID(memberID) + })), + lastJoinedPartner, + // TODO: The UI should display display names, usernames and (in the future) + // identity contract IDs differently in some way (e.g., font, font size, + // prefix (@), etc.) to make it impossible (or at least obvious) to impersonate + // users (e.g., 'user1' changing their display name to 'user2') + title: isDMToMyself + ? getters.userDisplayNameFromID(myIdendityId) + : partners.map(cID => getters.userDisplayNameFromID(cID)).join(', '), + lastMsgTimeStamp, + picture: getters.ourContactProfilesById[lastJoinedPartner]?.picture, + isDMToMyself // Can be useful when certain things in UI are meant only for 'DM to myself' + } + } + } + return currentGroupDirectMessages + } + }, + ourGroupDirectMessages (state, getters, rootState) { + return getters.directMessagesByGroup(rootState.currentGroupId) + }, + // NOTE: this getter is used to find the ID of the direct message in the current group + // with the name[s] of partner[s]. Normally it's more useful to find direct message + // by the partners instead of contractID + ourGroupDirectMessageFromUserIds (state, getters, rootState) { + return (partners) => { // NOTE: string | string[] + if (typeof partners === 'string') { + partners = [partners] + } + + const shouldFindDMToMyself = partners.length === 1 && partners[0] === rootState.loggedIn.identityContractID + const currentGroupDirectMessages = getters.ourGroupDirectMessages + return Object.keys(currentGroupDirectMessages).find(chatRoomID => { + const chatRoomSettings = currentGroupDirectMessages[chatRoomID] + + if (shouldFindDMToMyself) return chatRoomSettings.isDMToMyself + else { + const cPartners = chatRoomSettings.partners.map(partner => partner.contractID) + return cPartners.length === partners.length && union(cPartners, ((partners: any): string[])).length === partners.length + } + }) + } + }, + isGroupDirectMessage (state, getters) { + // NOTE: identity contract could not be synced at the time of calling this getter + return chatRoomID => !!getters.ourGroupDirectMessages[chatRoomID || getters.currentChatRoomId] + }, + isDirectMessage (state, getters) { + // NOTE: identity contract could not be synced at the time of calling this getter + return chatRoomID => !!getters.ourDirectMessages[chatRoomID || getters.currentChatRoomId] + }, + isGroupDirectMessageToMyself (state, getters) { + return chatRoomID => { + const chatRoomSettings = getters.ourGroupDirectMessages[chatRoomID || getters.currentChatRoomId] + return !!chatRoomSettings && chatRoomSettings?.isDMToMyself + } + }, + isJoinedChatRoom (state, getters, rootState) { + return (chatRoomID: string, memberID?: string) => !!rootState[chatRoomID]?.members?.[memberID || getters.ourIdentityContractId] + }, + currentChatVm (state, getters, rootState) { + return rootState?.[getters.currentChatRoomId]?._vm || null + }, + currentChatRoomScrollPosition (state, getters) { + return state.chatRoomScrollPosition?.[getters.currentChatRoomId] // undefined means to the latest + }, + currentChatRoomReadUntil (state, getters) { + // NOTE: Optional Chaining (?) is necessary when user viewing the chatroom which he is not part of + return getters.ourUnreadMessages[getters.currentChatRoomId]?.readUntil // undefined means to the latest + }, + chatRoomUnreadMessages (state, getters) { + return (chatRoomID: string) => { + // NOTE: Optional Chaining (?) is necessary when user tries to get mentions of the chatroom which he is not part of + return getters.ourUnreadMessages[chatRoomID]?.unreadMessages || [] + } + }, + groupUnreadMessages (state, getters, rootState) { + return (groupID: string) => { + const isGroupDirectMessage = cID => Object.keys(getters.directMessagesByGroup(groupID)).includes(cID) + const isGroupChatroom = cID => Object.keys(rootState[groupID]?.chatRooms || {}).includes(cID) + return Object.keys(getters.ourUnreadMessages) + .filter(cID => isGroupDirectMessage(cID) || isGroupChatroom(cID)) + .map(cID => getters.ourUnreadMessages[cID].unreadMessages.length) + .reduce((sum, n) => sum + n, 0) + } + }, + groupIdFromChatRoomId (state, getters, rootState) { + return (chatRoomID: string) => Object.keys(rootState.contracts) + .find(cId => rootState.contracts[cId].type === 'gi.contracts/group' && + Object.keys(rootState[cId].chatRooms).includes(chatRoomID)) + }, + chatRoomsInDetail (state, getters, rootState) { + const chatRoomsInDetail = merge({}, getters.groupChatRooms) + for (const contractID in chatRoomsInDetail) { + const chatRoom = rootState[contractID] + if (chatRoom && chatRoom.attributes && + chatRoom.members[rootState.loggedIn.identityContractID]) { + chatRoomsInDetail[contractID] = { + ...chatRoom.attributes, + id: contractID, + unreadMessagesCount: getters.chatRoomUnreadMessages(contractID).length, + joined: true + } + } else { + const { name, privacyLevel } = chatRoomsInDetail[contractID] + chatRoomsInDetail[contractID] = { id: contractID, name, privacyLevel, joined: false } + } + } + return chatRoomsInDetail + }, + mentionableChatroomsInDetails (state, getters) { + // NOTE: Channel types a user can mention + // 1. All public/group channels (regardless of whether joined or not). + // 2. A private channel that he/she has joined. + return Object.values(getters.chatRoomsInDetail).filter( + (details: any) => [CHATROOM_PRIVACY_LEVEL.GROUP, CHATROOM_PRIVACY_LEVEL.PUBLIC].includes(details.privacyLevel) || + (details.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE && details.joined) + ) + }, + getChatroomNameById (state, getters) { + return chatRoomID => { + const found: any = Object.values(getters.chatRoomsInDetail).find((details: any) => details.id === chatRoomID) + return found ? found.name : null + } + }, + chatRoomMembersInSort (state, getters) { + return getters.groupMembersSorted + .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) + .filter(member => !!getters.chatRoomMembers[member.contractID]) || [] + } +} + +export default getters diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js index 79a6c011c1..06b56c6825 100644 --- a/frontend/model/chatroom/vuexModule.js +++ b/frontend/model/chatroom/vuexModule.js @@ -1,8 +1,8 @@ 'use strict' import sbp from '@sbp/sbp' -import { merge, cloneDeep, union } from '@model/contracts/shared/giLodash.js' -import { MESSAGE_NOTIFY_SETTINGS, CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' +import { cloneDeep } from '@model/contracts/shared/giLodash.js' +import getters from './getters.js' import Vue from 'vue' const defaultState = { @@ -14,200 +14,6 @@ const defaultState = { } // getters -const getters = { - currentChatRoomId (state, getters, rootState) { - return state.currentChatRoomIDs[rootState.currentGroupId] || null - }, - currentChatRoomState (state, getters, rootState) { - return rootState[getters.currentChatRoomId] || {} // avoid "undefined" vue errors at inoportune times - }, - chatNotificationSettings (state) { - return Object.assign({ - default: { - messageNotification: MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES, - messageSound: MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES - } - }, state.chatNotificationSettings || {}) - }, - ourUnreadMessages (state) { - return state.unreadMessages || {} - }, - directMessagesByGroup (state, getters, rootState) { - return groupID => { - const currentGroupDirectMessages = {} - - if (!groupID) { - // NOTE: groupID could be null before finish syncing group contracts - return currentGroupDirectMessages - } - - for (const chatRoomID of Object.keys(getters.ourDirectMessages)) { - const chatRoomState = rootState[chatRoomID] - const directMessageSettings = getters.ourDirectMessages[chatRoomID] - const myIdendityId = getters.ourIdentityContractId - - // NOTE: skip DMs whose chatroom contracts are not synced yet - if (!chatRoomState || !chatRoomState.members?.[myIdendityId]) { - continue - } - // NOTE: direct messages should be filtered to the ones which are visible and of active group members - const members = Object.keys(chatRoomState.members) - const isDMToMyself = members.length === 1 && members[0] === myIdendityId - const partners = members - .filter(memberID => memberID !== myIdendityId) - .sort((p1, p2) => { - const p1JoinedDate = new Date(chatRoomState.members[p1].joinedDate).getTime() - const p2JoinedDate = new Date(chatRoomState.members[p2].joinedDate).getTime() - return p1JoinedDate - p2JoinedDate - }) - const hasActiveMember = partners.some(memberID => Object.keys(getters.profilesByGroup(groupID)).includes(memberID)) - if (directMessageSettings.visible && (isDMToMyself || hasActiveMember)) { - // NOTE: lastJoinedParter is chatroom member who has joined the chatroom for the last time. - // His profile picture can be used as the picture of the direct message - // possibly with the badge of the number of partners. - const lastJoinedPartner = isDMToMyself ? myIdendityId : partners[partners.length - 1] - const lastMsgTimeStamp = chatRoomState.messages?.length > 0 - ? new Date(chatRoomState.messages[chatRoomState.messages.length - 1].datetime).getTime() - : 0 - - currentGroupDirectMessages[chatRoomID] = { - ...directMessageSettings, - members, - partners: partners.map(memberID => ({ - contractID: memberID, - username: getters.usernameFromID(memberID), - displayName: getters.userDisplayNameFromID(memberID) - })), - lastJoinedPartner, - // TODO: The UI should display display names, usernames and (in the future) - // identity contract IDs differently in some way (e.g., font, font size, - // prefix (@), etc.) to make it impossible (or at least obvious) to impersonate - // users (e.g., 'user1' changing their display name to 'user2') - title: isDMToMyself - ? getters.userDisplayNameFromID(myIdendityId) - : partners.map(cID => getters.userDisplayNameFromID(cID)).join(', '), - lastMsgTimeStamp, - picture: getters.ourContactProfilesById[lastJoinedPartner]?.picture, - isDMToMyself // Can be useful when certain things in UI are meant only for 'DM to myself' - } - } - } - return currentGroupDirectMessages - } - }, - ourGroupDirectMessages (state, getters, rootState) { - return getters.directMessagesByGroup(rootState.currentGroupId) - }, - // NOTE: this getter is used to find the ID of the direct message in the current group - // with the name[s] of partner[s]. Normally it's more useful to find direct message - // by the partners instead of contractID - ourGroupDirectMessageFromUserIds (state, getters, rootState) { - return (partners) => { // NOTE: string | string[] - if (typeof partners === 'string') { - partners = [partners] - } - - const shouldFindDMToMyself = partners.length === 1 && partners[0] === rootState.loggedIn.identityContractID - const currentGroupDirectMessages = getters.ourGroupDirectMessages - return Object.keys(currentGroupDirectMessages).find(chatRoomID => { - const chatRoomSettings = currentGroupDirectMessages[chatRoomID] - - if (shouldFindDMToMyself) return chatRoomSettings.isDMToMyself - else { - const cPartners = chatRoomSettings.partners.map(partner => partner.contractID) - return cPartners.length === partners.length && union(cPartners, partners).length === partners.length - } - }) - } - }, - isGroupDirectMessage (state, getters) { - // NOTE: identity contract could not be synced at the time of calling this getter - return chatRoomID => !!getters.ourGroupDirectMessages[chatRoomID || getters.currentChatRoomId] - }, - isDirectMessage (state, getters) { - // NOTE: identity contract could not be synced at the time of calling this getter - return chatRoomID => !!getters.ourDirectMessages[chatRoomID || getters.currentChatRoomId] - }, - isGroupDirectMessageToMyself (state, getters) { - return chatRoomID => { - const chatRoomSettings = getters.ourGroupDirectMessages[chatRoomID || getters.currentChatRoomId] - return !!chatRoomSettings && chatRoomSettings?.isDMToMyself - } - }, - isJoinedChatRoom (state, getters, rootState) { - return (chatRoomID: string, memberID?: string) => !!rootState[chatRoomID]?.members?.[memberID || getters.ourIdentityContractId] - }, - currentChatVm (state, getters, rootState) { - return rootState?.[getters.currentChatRoomId]?._vm || null - }, - currentChatRoomScrollPosition (state, getters) { - return state.chatRoomScrollPosition[getters.currentChatRoomId] // undefined means to the latest - }, - currentChatRoomReadUntil (state, getters) { - // NOTE: Optional Chaining (?) is necessary when user viewing the chatroom which he is not part of - return getters.ourUnreadMessages[getters.currentChatRoomId]?.readUntil // undefined means to the latest - }, - chatRoomUnreadMessages (state, getters) { - return (chatRoomID: string) => { - // NOTE: Optional Chaining (?) is necessary when user tries to get mentions of the chatroom which he is not part of - return getters.ourUnreadMessages[chatRoomID]?.unreadMessages || [] - } - }, - groupUnreadMessages (state, getters, rootState) { - return (groupID: string) => { - const isGroupDirectMessage = cID => Object.keys(getters.directMessagesByGroup(groupID)).includes(cID) - const isGroupChatroom = cID => Object.keys(rootState[groupID]?.chatRooms || {}).includes(cID) - return Object.keys(getters.ourUnreadMessages) - .filter(cID => isGroupDirectMessage(cID) || isGroupChatroom(cID)) - .map(cID => getters.ourUnreadMessages[cID].unreadMessages.length) - .reduce((sum, n) => sum + n, 0) - } - }, - groupIdFromChatRoomId (state, getters, rootState) { - return (chatRoomID: string) => Object.keys(rootState.contracts) - .find(cId => rootState.contracts[cId].type === 'gi.contracts/group' && - Object.keys(rootState[cId].chatRooms).includes(chatRoomID)) - }, - chatRoomsInDetail (state, getters, rootState) { - const chatRoomsInDetail = merge({}, getters.groupChatRooms) - for (const contractID in chatRoomsInDetail) { - const chatRoom = rootState[contractID] - if (chatRoom && chatRoom.attributes && - chatRoom.members[rootState.loggedIn.identityContractID]) { - chatRoomsInDetail[contractID] = { - ...chatRoom.attributes, - id: contractID, - unreadMessagesCount: getters.chatRoomUnreadMessages(contractID).length, - joined: true - } - } else { - const { name, privacyLevel } = chatRoomsInDetail[contractID] - chatRoomsInDetail[contractID] = { id: contractID, name, privacyLevel, joined: false } - } - } - return chatRoomsInDetail - }, - mentionableChatroomsInDetails (state, getters) { - // NOTE: Channel types a user can mention - // 1. All public/group channels (regardless of whether joined or not). - // 2. A private channel that he/she has joined. - return Object.values(getters.chatRoomsInDetail).filter( - (details: any) => [CHATROOM_PRIVACY_LEVEL.GROUP, CHATROOM_PRIVACY_LEVEL.PUBLIC].includes(details.privacyLevel) || - (details.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE && details.joined) - ) - }, - getChatroomNameById (state, getters) { - return chatRoomID => { - const found: any = Object.values(getters.chatRoomsInDetail).find((details: any) => details.id === chatRoomID) - return found ? found.name : null - } - }, - chatRoomMembersInSort (state, getters) { - return getters.groupMembersSorted - .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) - .filter(member => !!getters.chatRoomMembers[member.contractID]) || [] - } -} // mutations const mutations = { diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 8f6d9d8701..7e9a88be80 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -4,6 +4,7 @@ import { L } from '@common/common.js' import sbp from '@sbp/sbp' +import { NEW_CHATROOM_UNREAD_POSITION } from '@utils/events.js' import { actionRequireInnerSignature, arrayOf, number, object, objectOf, optional, string, stringMax } from '~/frontend/model/contracts/misc/flowTyper.js' import { ChelErrorGenerator } from '~/shared/domains/chelonia/errors.js' import { findForeignKeysByContractID, findKeyIdByName } from '~/shared/domains/chelonia/utils.js' @@ -418,8 +419,8 @@ sbp('chelonia/defineContract', { const rootState = sbp('state/vuex/state') const me = rootState.loggedIn.identityContractID - if (rootState.chatroom.chatRoomScrollPosition[contractID] === data.hash) { - sbp('state/vuex/commit', 'setChatRoomScrollPosition', { + if (rootState.chatroom?.chatRoomScrollPosition?.[contractID] === data.hash) { + sbp('okTurtles.events/emit', NEW_CHATROOM_UNREAD_POSITION, { chatRoomID: contractID, messageHash: null }) } diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 8c6d7a21f3..dd9b525078 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -682,7 +682,7 @@ sbp('chelonia/defineContract', { // TODO: create a global timer to auto-pass/archive expired votes // make sure to set that proposal's status as STATUS_EXPIRED if it's expired }, - sideEffect ({ contractID, meta, data, height, innerSigningContractID }, { getters }) { + sideEffect ({ contractID, meta, hash, data, height, innerSigningContractID }, { getters }) { const { loggedIn } = sbp('state/vuex/state') const typeToSubTypeMap = { [PROPOSAL_INVITE_MEMBER]: 'ADD_MEMBER', @@ -702,6 +702,7 @@ sbp('chelonia/defineContract', { createdDate: meta.createdDate, groupID: contractID, creatorID: innerSigningContractID, + proposalHash: hash, subtype: typeToSubTypeMap[data.proposalType] }) } @@ -1545,10 +1546,10 @@ sbp('chelonia/defineContract', { await sbp('gi.db/archive/delete', archPaymentsByPeriodKey) await sbp('gi.db/archive/delete', archSentOrReceivedPaymentsKey) }, - 'gi.contracts/group/makeNotificationWhenProposalClosed': function (state, contractID, meta, height, proposal) { + 'gi.contracts/group/makeNotificationWhenProposalClosed': function (state, contractID, meta, height, proposalHash, proposal) { const { loggedIn } = sbp('state/vuex/state') if (isActionNewerThanUserJoinedDate(height, state.profiles[loggedIn.identityContractID])) { - sbp('gi.notifications/emit', 'PROPOSAL_CLOSED', { createdDate: meta.createdDate, groupID: contractID, proposal }) + sbp('gi.notifications/emit', 'PROPOSAL_CLOSED', { createdDate: meta.createdDate, groupID: contractID, proposalHash, proposal }) } }, 'gi.contracts/group/sendMincomeChangedNotification': async function (contractID, meta, data, height, innerSigningContractID) { diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index 7f5c5d786c..45a3613a92 100644 --- a/frontend/model/contracts/shared/functions.js +++ b/frontend/model/contracts/shared/functions.js @@ -14,6 +14,7 @@ import { PROPOSAL_GENERIC, CHATROOM_MEMBER_MENTION_SPECIAL_CHAR } from './constants.js' +import { NEW_CHATROOM_UNREAD_POSITION } from '@utils/events.js' import { humanDate } from './time.js' // !!!!!!!!!!!!!!! @@ -169,7 +170,7 @@ export async function leaveChatRoom (contractID: string, state: Object) { sbp('gi.actions/identity/kv/deleteChatRoomUnreadMessages', { contractID }).catch((e) => { console.error('[leaveChatroom] Error at deleteChatRoomUnreadMessages ', contractID, e) }) - await sbp('state/vuex/commit', 'deleteChatRoomScrollPosition', { chatRoomID: contractID }) + sbp('okTurtles.events/emit', NEW_CHATROOM_UNREAD_POSITION, { chatRoomID: contractID }) // NOTE: The contract that keeps track of chatrooms should now call `/release` // This would be the group contract (for group chatrooms) or the identity // contract (for DMs). diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index 5d8333ceec..693fa38cd6 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -33,7 +33,7 @@ export function notifyAndArchiveProposal ({ state, proposalHash, proposal, contr // because we remove the state.proposals[proposalHash] in the process function // and can not access the proposal data in the sideEffect sbp('gi.contracts/group/pushSideEffect', contractID, - ['gi.contracts/group/makeNotificationWhenProposalClosed', state, contractID, meta, height, proposal] + ['gi.contracts/group/makeNotificationWhenProposalClosed', state, contractID, meta, height, proposalHash, proposal] ) sbp('gi.contracts/group/pushSideEffect', contractID, ['gi.contracts/group/archiveProposal', contractID, proposalHash, proposal] diff --git a/frontend/model/database.js b/frontend/model/database.js index 0b3aa38f8b..8a97aa9c37 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -3,102 +3,112 @@ import sbp from '@sbp/sbp' import { CURVE25519XSALSA20POLY1305, decrypt, encrypt, generateSalt, keyId, keygen, serializeKey } from '../../shared/domains/chelonia/crypto.js' -const _instances = [] +const _instances: (() => Promise<*>)[] = [] // Localforage-like API for IndexedDB const localforage = { async ready () { - await Promise.all(_instances).then(() => {}) + await Promise.all(_instances.map((lazyInitDb) => lazyInitDb())) }, createInstance ({ name, storeName }: { name: string, storeName: string }) { // Open the IndexedDB database - const db = new Promise((resolve, reject) => { - if (name.includes('-') || storeName.includes('-')) { - reject(new Error('Unsupported characters in name: -')) - return - } - const request = self.indexedDB.open(name + '--' + storeName) + // We lazy load the IndexedDB, because before we were loading it when this + // file was imported, but on iOS IndexedDB is not available in the service + // worker when the PWA is in the background. + // + // So for that reason, and potentially other situations where IndexedDB + // might not be available, we lazy load it like this upon creation. + const lazyInitDb = (() => { + let promise + return () => { + if (!promise) { + promise = new Promise((resolve, reject) => { + if (name.includes('-') || storeName.includes('-')) { + reject(new Error('Unsupported characters in name: -')) + return + } + const request = self.indexedDB.open(name + '--' + storeName) - // Create the object store if it doesn't exist - request.onupgradeneeded = (event) => { - const db = event.target.result - db.createObjectStore(storeName) - } + // Create the object store if it doesn't exist + request.onupgradeneeded = (event) => { + const db = event.target.result + db.createObjectStore(storeName) + } - request.onsuccess = (event) => { - const db = event.target.result - resolve(db) - } + request.onsuccess = (event) => { + const db = event.target.result + resolve(db) + } - request.onerror = (error) => { - reject(error) - } + request.onerror = (error) => { + reject(error) + } - request.onblocked = (event) => { - reject(new Error('DB is blocked')) + request.onblocked = (event) => { + reject(new Error('DB is blocked')) + } + }) + } + return promise } - }) + })() - _instances.push(db) + _instances.push(lazyInitDb) return { - clear () { - return db.then(db => { - const transaction = db.transaction([storeName], 'readwrite') - const objectStore = transaction.objectStore(storeName) - const request = objectStore.clear() - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve() - } - request.onerror = (e) => { - reject(e) - } - }) + async clear () { + const db = await lazyInitDb() + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.clear() + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e) + } }) }, - getItem (key: string) { - return db.then(db => { - const transaction = db.transaction([storeName], 'readonly') - const objectStore = transaction.objectStore(storeName) - const request = objectStore.get(key) - return new Promise((resolve, reject) => { - request.onsuccess = (event) => { - resolve(event.target.result) - } - request.onerror = (e) => { - reject(e) - } - }) + async getItem (key: string) { + const db = await lazyInitDb() + const transaction = db.transaction([storeName], 'readonly') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.get(key) + return new Promise((resolve, reject) => { + request.onsuccess = (event) => { + resolve(event.target.result) + } + request.onerror = (e) => { + reject(e) + } }) }, - removeItem (key: string) { - return db.then(db => { - const transaction = db.transaction([storeName], 'readwrite') - const objectStore = transaction.objectStore(storeName) - const request = objectStore.delete(key) - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve() - } - request.onerror = (e) => { - reject(e.target.error) - } - }) + async removeItem (key: string) { + const db = await lazyInitDb() + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.delete(key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } }) }, - setItem (key: string, value: any) { - return db.then(db => { - const transaction = db.transaction([storeName], 'readwrite') - const objectStore = transaction.objectStore(storeName) - const request = objectStore.put(value, key) - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve() - } - request.onerror = (e) => { - reject(e.target.error) - } - }) + async setItem (key: string, value: any) { + const db = await lazyInitDb() + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.put(value, key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } }) } } @@ -204,7 +214,12 @@ sbp('sbp/selectors/register', { // (2) salt // (3) encryptedStateEncryptionKey (used for recovery when re-logging in) // (4) encryptedState - return appSettings.setItem('e' + user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`) + return appSettings.setItem('e' + user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`).finally(() => { + // Delete the unencypted setting key, if it exists + sbp('gi.db/settings/delete', user).catch(e => { + console.error('[gi.db/settings/saveEncrypted] Error deleting unencrypted data for user', user, e) + }) + }) }, 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<*> { return appSettings.getItem('e' + user).then(async (encryptedValue) => { @@ -351,7 +366,7 @@ sbp('sbp/selectors/register', { await filesCache.setItem('keys', keys) } }).catch(e => { - console.error('[gi.db/filesCache/load] Error updating keys') + console.error('[gi.db/filesCache/delete] Error updating keys') }) }, 'gi.db/filesCache/clear': async function (): Promise { @@ -360,7 +375,7 @@ sbp('sbp/selectors/register', { }) // ====================================== -// Archve for proposals and anything else +// Archive for proposals and anything else // ====================================== const archive = localforage.createInstance({ @@ -382,3 +397,27 @@ sbp('sbp/selectors/register', { return archive.clear() } }) + +// ====================================== +// Application logs, used for the SW logs +// ====================================== + +const logs = localforage.createInstance({ + name: 'Group Income', + storeName: 'Logs' +}) + +sbp('sbp/selectors/register', { + 'gi.db/logs/save': function (key: string, value: any): Promise<*> { + return logs.setItem(key, value) + }, + 'gi.db/logs/load': function (key: string): Promise { + return logs.getItem(key) + }, + 'gi.db/logs/delete': function (key: string): Promise { + return logs.removeItem(key) + }, + 'gi.db/logs/clear': function (): Promise { + return logs.clear() + } +}) diff --git a/frontend/model/getters.js b/frontend/model/getters.js new file mode 100644 index 0000000000..071a975194 --- /dev/null +++ b/frontend/model/getters.js @@ -0,0 +1,484 @@ +import { L } from '@common/common.js' +import { INVITE_INITIAL_CREATOR, PROFILE_STATUS } from '@model/contracts/shared/constants.js' +import { INVITE_STATUS } from '~/shared/domains/chelonia/constants.js' +import { adjustedDistribution, unadjustedDistribution } from '@model/contracts/shared/distribution/distribution.js' +import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' +import chatroomGetters from './contracts/shared/getters/chatroom.js' +import groupGetters from './contracts/shared/getters/group.js' +import identityGetters from './contracts/shared/getters/identity.js' + +const checkedUsername = (state: Object, username: string, userID: string) => { + if (username && state.namespaceLookups[username] === userID) { + return username + } +} + +// Find the 'anyone can join' invite ID. Since there could be multiple, and some +// of those could have exipred, we need a for loop +const anyoneCanJoinInviteId = (invites: Object, getters: Object): ?string => + Object.keys(invites).find(invite => + // First, we want 'anyone can join' invites + invites[invite].creatorID === INVITE_INITIAL_CREATOR && + // and that haven't been revoked + getters.currentGroupState._vm.invites[invite].status === INVITE_STATUS.VALID && + // and that haven't expired (using negative logic because expires could be + // undefined for non expiring-invites) + !(getters.currentGroupState._vm.invites[invite].expires < Date.now()) && + // and that that haven't been entirely used up + !(getters.currentGroupState._vm.invites[invite].quantity <= 0) + ) + +// https://vuex.vuejs.org/en/getters.html +// https://vuex.vuejs.org/en/modules.html +const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => any } = { + // !! IMPORTANT !! + // + // We register pure Vuex getters here, but later on at the bottom of this file, + // we will also import into Vuex the contract getters so that they can be reused + // without having to be redefined. This is possible because Chelonia contract getters + // are designed to be compatible with Vuex getters. + // + // We will use the getters 'currentGroupState', 'currentIdentityState', and + // 'currentChatRoomState' as a "bridge" between the contract getters and Vuex. + // + // This makes it possible for the getters inside of contracts to refer to each + // specific contractID instance, while the Vuex version of those getters that + // are imported at the bottom of this file (in the listener for CONTRACT_REGISTERED + // will reference the state for the specific contractID for either the current group, + // the current user identity contract, or the current chatroom we're looking at. + // + // For getters that get data from only contract state, write them + // under the 'getters' key of the object passed to 'chelonia/defineContract'. + // See for example: frontend/model/contracts/group.js + // + // Again, for convenience, we've defined the same getter, `currentGroupState`, + // twice, so that we can reuse the same getter definitions both here with Vuex, + // and inside of the contracts (e.g. in group.js). + // + // The 'currentGroupState' here is based off the value of `state.currentGroupId`, + // a user preference that does not exist in the group contract state. + currentGroupState (state) { + return state[state.currentGroupId] || {} // avoid "undefined" vue errors at inoportune times + }, + currentIdentityState (state) { + return (state.loggedIn && state[state.loggedIn.identityContractID]) || {} + }, + ourUsername (state, getters) { + return state.loggedIn && getters.usernameFromID(state.loggedIn.identityContractID) + }, + ourPreferences (state) { + return state.preferences + }, + ourProfileActive (state, getters) { + return getters.profileActive(getters.ourIdentityContractId) + }, + ourPendingAccept (state, getters) { + return getters.pendingAccept(getters.ourIdentityContractId) + }, + ourGroupProfile (state, getters) { + return getters.groupProfile(getters.ourIdentityContractId) + }, + ourUserDisplayName (state, getters) { + // TODO - refactor Profile and Welcome and any other component that needs this + const userContract = getters.currentIdentityState || {} + return userContract.attributes?.displayName || getters.ourUsername || getters.ourIdentityContractId + }, + ourIdentityContractId (state) { + return state.loggedIn && state.loggedIn.identityContractID + }, + currentGroupLastLoggedIn (state) { + return state.lastLoggedIn[state.currentGroupId] || {} + }, + // NOTE: since this getter is written using `getters.ourUsername`, which is based + // on vuexState.loggedIn (a user preference), we cannot use this getter + // into group.js + ourContributionSummary (state, getters) { + const groupProfiles = getters.groupProfiles + const ourIdentityContractId = getters.ourIdentityContractId + const ourGroupProfile = getters.ourGroupProfile + + if (!ourGroupProfile || !ourGroupProfile.incomeDetailsType) { + return {} + } + + const doWeNeedIncome = ourGroupProfile.incomeDetailsType === 'incomeAmount' + const distribution = getters.groupIncomeDistribution + + const nonMonetaryContributionsOf = (memberID) => groupProfiles[memberID].nonMonetaryContributions || [] + + return { + givingMonetary: (() => { + if (doWeNeedIncome) { return null } + const who = [] + const total = distribution + .filter(p => p.fromMemberID === ourIdentityContractId) + .reduce((acc, payment) => { + who.push(getters.userDisplayNameFromID(payment.toMemberID)) + return acc + payment.amount + }, 0) + + return { who, total, pledged: ourGroupProfile.pledgeAmount } + })(), + receivingMonetary: (() => { + if (!doWeNeedIncome) { return null } + const needed = getters.groupSettings.mincomeAmount - ourGroupProfile.incomeAmount + const who = [] + const total = distribution + .filter(p => p.toMemberID === ourIdentityContractId) + .reduce((acc, payment) => { + who.push(getters.userDisplayNameFromID(payment.fromMemberID)) + return acc + payment.amount + }, 0) + + return { who, total, needed } + })(), + receivingNonMonetary: (() => { + const listWho = Object.keys(groupProfiles) + .filter(memberID => memberID !== ourIdentityContractId && nonMonetaryContributionsOf(memberID).length > 0) + const listWhat = listWho.reduce((contr, memberID) => { + const displayName = getters.userDisplayNameFromID(memberID) + const userContributions = nonMonetaryContributionsOf(memberID) + + userContributions.forEach((what) => { + const contributionIndex = contr.findIndex(c => c.what === what) + contributionIndex >= 0 + ? contr[contributionIndex].who.push(displayName) + : contr.push({ who: [displayName], what }) + }) + return contr + }, []) + + return listWho.length > 0 ? { what: listWhat, who: listWho } : null + })(), + givingNonMonetary: (() => { + const contributions = ourGroupProfile.nonMonetaryContributions + + return contributions.length > 0 ? contributions : null + })() + } + }, + usernameFromID (state, getters) { + return (userID) => { + const profile = getters.ourContactProfilesById[userID] + return profile?.username || state.reverseNamespaceLookups[userID] || userID + } + }, + userDisplayNameFromID (state, getters) { + return (userID) => { + if (userID === getters.ourIdentityContractId) { + return getters.ourUserDisplayName + } + const profile = getters.ourContactProfilesById[userID] + return profile?.displayName || profile?.username || state.reverseNamespaceLookups[userID] || userID + } + }, + thisPeriodPaymentInfo (state, getters) { + return getters.groupPeriodPayments[getters.currentPaymentPeriod] + }, + latePayments (state, getters) { + const periodPayments = getters.groupPeriodPayments + if (Object.keys(periodPayments).length === 0) return + const ourIdentityContractId = getters.ourIdentityContractId + const pPeriod = getters.periodBeforePeriod(getters.currentPaymentPeriod) + const pPayments = periodPayments[pPeriod] + if (pPayments) { + return pPayments.lastAdjustedDistribution.filter(todo => todo.fromMemberID === ourIdentityContractId) + } + }, + // used with graphs like those in the dashboard and in the income details modal + groupIncomeDistribution (state, getters) { + return unadjustedDistribution({ + haveNeeds: getters.haveNeedsForThisPeriod(getters.currentPaymentPeriod), + minimize: false + }) + }, + // adjusted version of groupIncomeDistribution, used by the payments system + groupIncomeAdjustedDistribution (state, getters) { + const paymentInfo = getters.thisPeriodPaymentInfo + if (paymentInfo && paymentInfo.lastAdjustedDistribution) { + return paymentInfo.lastAdjustedDistribution + } else { + const period = getters.currentPaymentPeriod + return adjustedDistribution({ + distribution: unadjustedDistribution({ + haveNeeds: getters.haveNeedsForThisPeriod(period), + minimize: getters.groupSettings.minimizeDistribution + }), + payments: getters.paymentsForPeriod(period), + dueOn: getters.dueDateForPeriod(period) + }) + } + }, + ourPaymentsSentInPeriod (state, getters) { + return (period) => { + const periodPayments = getters.groupPeriodPayments + if (Object.keys(periodPayments).length === 0) return + const payments = [] + const thisPeriodPayments = periodPayments[period] + const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom + if (paymentsFrom) { + const ourIdentityContractId = getters.ourIdentityContractId + const allPayments = getters.currentGroupState.payments + for (const toMemberID in paymentsFrom[ourIdentityContractId]) { + for (const paymentHash of paymentsFrom[ourIdentityContractId][toMemberID]) { + const { data, meta, height } = allPayments[paymentHash] + + payments.push({ hash: paymentHash, height, data, meta, amount: data.amount, period }) + } + } + } + return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) + } + }, + ourPaymentsReceivedInPeriod (state, getters) { + return (period) => { + const periodPayments = getters.groupPeriodPayments + if (Object.keys(periodPayments).length === 0) return + const payments = [] + const thisPeriodPayments = periodPayments[period] + const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom + if (paymentsFrom) { + const ourIdentityContractId = getters.ourIdentityContractId + const allPayments = getters.currentGroupState.payments + for (const fromMemberID in paymentsFrom) { + for (const toMemberID in paymentsFrom[fromMemberID]) { + if (toMemberID === ourIdentityContractId) { + for (const paymentHash of paymentsFrom[fromMemberID][toMemberID]) { + const { data, meta, height } = allPayments[paymentHash] + + payments.push({ hash: paymentHash, height, data, meta, amount: data.amount }) + } + } + } + } + } + return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) + } + }, + ourPayments (state, getters) { + const periodPayments = getters.groupPeriodPayments + if (Object.keys(periodPayments).length === 0) return + const ourIdentityContractId = getters.ourIdentityContractId + const cPeriod = getters.currentPaymentPeriod + const pPeriod = getters.periodBeforePeriod(cPeriod) + const currentSent = getters.ourPaymentsSentInPeriod(cPeriod) + const previousSent = getters.ourPaymentsSentInPeriod(pPeriod) + const currentReceived = getters.ourPaymentsReceivedInPeriod(cPeriod) + const previousReceived = getters.ourPaymentsReceivedInPeriod(pPeriod) + + // TODO: take into account pending payments that have been sent but not yet completed + const todo = () => { + return getters.groupIncomeAdjustedDistribution.filter(p => p.fromMemberID === ourIdentityContractId) + } + + return { + sent: [...currentSent, ...previousSent], + received: [...currentReceived, ...previousReceived], + todo: todo() + } + }, + ourPaymentsSummary (state, getters) { + const isNeeder = getters.ourGroupProfile.incomeDetailsType === 'incomeAmount' + const ourIdentityContractId = getters.ourIdentityContractId + const isOurPayment = (payment) => { + return isNeeder ? payment.toMemberID === ourIdentityContractId : payment.fromMemberID === ourIdentityContractId + } + const sumUpAmountReducer = (acc, payment) => acc + payment.amount + const cPeriod = getters.currentPaymentPeriod + const ourAdjustedPayments = getters.groupIncomeAdjustedDistribution.filter(isOurPayment) + const receivedOrSent = isNeeder + ? getters.ourPaymentsReceivedInPeriod(cPeriod) + : getters.ourPaymentsSentInPeriod(cPeriod) + + const markedAsNotReceived = receivedOrSent.filter(payment => payment.data.status === PAYMENT_NOT_RECEIVED) + const markedAsNotReceivedTotal = markedAsNotReceived.reduce(sumUpAmountReducer, 0) + + const paymentsTotal = ourAdjustedPayments.length + receivedOrSent.length + const nonLateAdjusted = ourAdjustedPayments.filter((p) => !p.isLate) + const paymentsDone = paymentsTotal - nonLateAdjusted.length - markedAsNotReceived.length + const hasPartials = ourAdjustedPayments.some(p => p.partial) + const amountDone = receivedOrSent.reduce(sumUpAmountReducer, 0) - markedAsNotReceivedTotal + const amountLeft = ourAdjustedPayments.reduce((acc, payment) => acc + payment.amount, 0) + markedAsNotReceivedTotal + const amountTotal = amountDone + amountLeft + return { + paymentsDone, + hasPartials, + paymentsTotal, + amountDone, + amountTotal + } + }, + currentWelcomeInvite (state, getters) { + const invites = getters.currentGroupState.invites + const inviteId = anyoneCanJoinInviteId(invites, getters) + const expires = getters.currentGroupState._vm.invites[inviteId].expires + return { inviteId, expires } + }, + // list of group names and contractIDs + groupsByName (state, getters) { + const identityContractID = getters.ourIdentityContractId + const groups = state[identityContractID]?.groups + if (!groups) return [] + // The code below was originally Object.entries(...) but changed to .keys() + // due to the same flow issue as https://github.com/facebook/flow/issues/5838 + // we return event pending groups that we haven't finished joining so that we are not stuck + // on the /pending-approval page if we are part of another working group already + return Object.entries(groups) + // $FlowFixMe[incompatible-use] + .filter(([, { hasLeft }]) => !hasLeft) + .map(([contractID]) => ({ groupName: state[contractID]?.settings?.groupName || L('Pending'), contractID, active: state[contractID]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE })) + }, + profilesByGroup (state, getters) { + return groupID => { + const profiles = {} + if (state.contracts[groupID]?.type !== 'gi.contracts/group') { + return profiles + } + const groupProfiles = state[groupID].profiles || {} + for (const member in groupProfiles) { + const profile = groupProfiles[member] + if (profile.status === PROFILE_STATUS.ACTIVE) { + profiles[member] = profile + } + } + return profiles + } + }, + groupMembersSorted (state, getters) { + const profiles = getters.currentGroupState.profiles + if (!profiles || !profiles[getters.ourIdentityContractId]) return [] + const weJoinedHeight = profiles[getters.ourIdentityContractId].joinedHeight + const isNewMember = (memberID) => { + if (memberID === getters.ourIdentityContractId) { return false } + const memberProfile = profiles[memberID] + if (!memberProfile) return false + const memberJoinedHeight = memberProfile.joinedHeight + const memberJoinedMs = new Date(memberProfile.joinedDate).getTime() + const joinedAfterUs = weJoinedHeight < memberJoinedHeight + return joinedAfterUs && Date.now() - memberJoinedMs < 604800000 // joined less than 1w (168h) ago. + } + + const groupMembersPending = getters.groupMembersPending + + // $FlowFixMe[method-unbinding] + return [groupMembersPending, getters.groupProfiles].flatMap(Object.keys) + .filter(memberID => getters.groupProfiles[memberID] || + !(getters.groupMembersPending[memberID].expires < Date.now())) + .map(memberID => { + const { contractID, displayName, username } = getters.globalProfile(memberID) || groupMembersPending[memberID] || (getters.groupProfiles[memberID] ? { contractID: memberID } : {}) + return { + id: memberID, // common unique ID: it can be either the contract ID or the invite key + contractID, + username, + displayName: displayName || username || memberID, + invitedBy: getters.groupMembersPending[memberID], + isNew: isNewMember(memberID) + } + }) + .sort((userA, userB) => { + const nameA = userA.displayName.normalize().toUpperCase() + const nameB = userB.displayName.normalize().toUpperCase() + // Show pending members first + if (userA.invitedBy && !userB.invitedBy) { return -1 } + if (!userA.invitedBy && userB.invitedBy) { return 1 } + // Then new members... + if (userA.isNew && !userB.isNew) { return -1 } + if (!userA.isNew && userB.isNew) { return 1 } + // and sort them all by A-Z + return nameA < nameB ? -1 : 1 + }) + }, + groupProposals (state, getters) { + return contractID => state[contractID]?.proposals + }, + globalProfile (state, getters) { + // get profile from username who is part of current group + return memberID => { + return getters.ourContactProfilesById[memberID] + } + }, + ourContactProfilesByUsername (state, getters) { + const profiles = {} + Object.keys(state.contracts) + .filter(contractID => state.contracts[contractID].type === 'gi.contracts/identity') + .forEach(contractID => { + const attributes = state[contractID].attributes + if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts + const username = checkedUsername(state, attributes.username, contractID) + if (!username) return + profiles[username] = { + ...attributes, + username, + contractID + } + } + }) + return profiles + }, + ourContactProfilesById (state, getters) { + const profiles = {} + Object.keys(state.contracts) + .filter(contractID => state.contracts[contractID].type === 'gi.contracts/identity') + .forEach(contractID => { + if (!state[contractID]) { + console.warn('[ourContactProfilesById] Missing state', contractID) + return + } + const attributes = state[contractID].attributes + if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts + const username = checkedUsername(state, attributes.username, contractID) + profiles[contractID] = { + ...attributes, + username, + contractID + } + } + }) + // For consistency, add users that were known in the past (since those + // contracts will be removed). This keeps mentions working in existing + // devices + Object.keys(state.reverseNamespaceLookups).forEach((contractID) => { + if (profiles[contractID]) return + profiles[contractID] = { + username: state.reverseNamespaceLookups[contractID], + contractID + } + }) + return profiles + }, + currentGroupContactProfilesById (state, getters) { + const currentGroupProfileIds = Object.keys(getters.currentGroupState.profiles || {}) + const filtered = {} + + for (const identityContractID in getters.ourContactProfilesById) { + if (currentGroupProfileIds.includes(identityContractID)) { + filtered[identityContractID] = getters.ourContactProfilesById[identityContractID] + } + } + return filtered + }, + ourContactsById (state, getters) { + return Object.keys(getters.ourContactProfilesById) + .sort((userIdA, userIdB) => { + const nameA = ((getters.ourContactProfilesById[userIdA].displayName)) || getters.ourContactProfilesById[userIdA].username || userIdA + const nameB = ((getters.ourContactProfilesById[userIdB].displayName)) || getters.ourContactProfilesById[userIdB].username || userIdB + return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 + }) + }, + ourContactsByUsername (state, getters) { + return Object.keys(getters.ourContactProfilesByUsername) + .sort((usernameA, usernameB) => { + const nameA = getters.ourContactProfilesByUsername[usernameA].displayName || usernameA + const nameB = getters.ourContactProfilesByUsername[usernameB].displayName || usernameB + return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 + }) + }, + seenWelcomeScreen (state, getters) { + return getters.ourProfileActive && getters.currentIdentityState?.groups?.[state.currentGroupId]?.seenWelcomeScreen + }, + ...chatroomGetters, + ...groupGetters, + ...identityGetters +} + +export default getters diff --git a/frontend/model/logServer.js b/frontend/model/logServer.js new file mode 100644 index 0000000000..70a430635e --- /dev/null +++ b/frontend/model/logServer.js @@ -0,0 +1,28 @@ +import sbp from '@sbp/sbp' +import '@sbp/okturtles.events' +import { CAPTURED_LOGS } from '~/frontend/utils/events.js' + +export default (console: Object): Function => { + // only log to server if we're in development mode and connected over the + // tunnel (which creates URLs that begin with 'https://gi' per Gruntfile.js) + if (process.env.NODE_ENV !== 'development' || !self.location.href.startsWith('https://gi')) return + + sbp('okTurtles.events/on', CAPTURED_LOGS, ({ level, msg: stringifyMe }) => { + if (level === 'debug') return // comment out to send much more log info + const value = JSON.stringify(stringifyMe) + // To avoid infinite loop because we log all selector calls, we run sbp calls + // here in a roundabout way by getting the function to which they're mapped. + // The reason this works is because the entire `sbp` domain is blacklisted + // from being logged. + const apiUrl = sbp('sbp/selectors/fn', 'okTurtles.data/get')('API_URL') + fetch(`${apiUrl}/log`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ level, value }) + }).catch(e => { + console.error(`[captureLogs] '${e.message}' attempting to log [${level}] to server:`, value) + }) + }) +} diff --git a/frontend/model/logger.js b/frontend/model/logger.js new file mode 100644 index 0000000000..dfd0399b9e --- /dev/null +++ b/frontend/model/logger.js @@ -0,0 +1,119 @@ +import sbp from '@sbp/sbp' +import { CAPTURED_LOGS } from '~/frontend/utils/events.js' +import CircularList from '~/shared/CircularList.js' + +const loggingLevels = ['debug', 'error', 'info', 'log', 'warn'] +const originalConsole = console +const noop = (...args: any) => undefined + +export async function createLogger (config: Object, { getItem, removeItem, setItem }: { getItem: Function, removeItem: Function, setItem: Function }): Object { + const entries = new CircularList(config.maxEntries) + const methods = Object.fromEntries(loggingLevels.map((name) => + [name, (...args) => { + originalConsole[name](...args) + captureLogEntry(logger, name, config.source, ...args) + }] + )) + const appLogsFilter: string[] = [] + // Make a copy of 'methods' to prevent reassignment by 'setAppLogsFilter' + const consoleProxy = new Proxy({ ...methods }, { + get (o, p, r) { + return Reflect.has(o, p) ? Reflect.get(o, p, r) : Reflect.get(originalConsole, p, r) + }, + has (o, p) { + return Reflect.has(originalConsole, p) + } + }) + const logger = { + get appLogsFilter () { + return [...appLogsFilter] + }, + console: consoleProxy, + entries, + async clear () { + await removeItem('entries') + entries.clear() + }, + async save () { + try { + await setItem('entries', this.entries.toArray()) + } catch (error) { + consoleProxy.error(error) + } + }, + setAppLogsFilter (filter: Array) { + appLogsFilter.splice(0, appLogsFilter.length, ...filter) + // NOTE: Find a way to capture logs without messing up with log file location. + // console.log() doesnt include stack trace, so when logged, we can't access + // where the log came from (file name), which} difficults debugging if needed. + for (const level of loggingLevels) { + // $FlowFixMe + consoleProxy[level] = appLogsFilter.includes(level) ? methods[level] : noop + } + } + } + + // Handle potential data corruption gracefully + const previousEntries = await (async () => { + try { + const stored = await getItem('entries') + return stored ? JSON.parse(stored) : [] + } catch (error) { + consoleProxy.error('Failed to parse stored entries:', error) + return [] + } + })() + + // If `maxEntries` is changed in a release, this will discard oldest logs as necessary. + if (config.maxEntries < previousEntries.length) { + previousEntries.splice(0, previousEntries.length - config.maxEntries) + } + // Load the previous entries to sync the in-memory array with the local storage. + if (previousEntries.length) { + logger.entries.addAll(previousEntries) + } + + return logger +} + +function captureLogEntry (logger: Object, type: string, source: string, ...args) { + const entry = { + timestamp: new Date().toISOString(), + source, + type, + // Detect when arg is an Error and capture it properly. + // ex: uncaught Vue errors or custom try/catch errors. + msg: args.map((arg) => { + try { + const seen = new WeakSet() + return JSON.parse( + JSON.stringify(arg, (_, v) => { + if (v instanceof Error) { + return { + name: v.name, + message: v.message, + stack: v.stack + } + } + // Handle circular references + if (typeof v === 'object' && v !== null) { + if (seen.has(v)) { + return '[Circular Reference]' + } + seen.add(v) + } + return v + }) + ) + } catch (e) { + return `[captureLogs failed to stringify argument of type '${typeof arg}'. Err: ${e.message}]` + } + }) + } + logger.entries.add(entry) + // To avoid infinite loop because we log all selector calls, we run sbp calls + // here in a roundabout way by getting the function to which they're mapped. + // The reason this works is because the entire `sbp` domain is blacklisted + // from being logged in main.js. + sbp('sbp/selectors/fn', 'okTurtles.events/emit')(CAPTURED_LOGS, entry) +} diff --git a/frontend/model/notifications/messageReceivePostEffect.js b/frontend/model/notifications/messageReceivePostEffect.js index e9af2e9543..d128128c78 100644 --- a/frontend/model/notifications/messageReceivePostEffect.js +++ b/frontend/model/notifications/messageReceivePostEffect.js @@ -25,46 +25,68 @@ async function messageReceivePostEffect ({ memberID: string, chatRoomName: string }): Promise { - // TODO: This can't be a root getter when running in a SW const rootGetters = await sbp('state/vuex/getters') - const isGroupDM = rootGetters.isGroupDirectMessage(contractID) + const rootState = await sbp('state/vuex/state') + const identityContractID = rootState.loggedIn?.identityContractID + if (!identityContractID) return + const isDM = rootState[identityContractID].chatRooms[contractID] const shouldAddToUnreadMessages = isDMOrMention || [MESSAGE_TYPES.INTERACTIVE, MESSAGE_TYPES.POLL].includes(messageType) - await sbp('chelonia/contract/wait', contractID) - if (shouldAddToUnreadMessages) { - sbp('gi.actions/identity/kv/addChatRoomUnreadMessage', { contractID, messageHash, createdHeight: height }) - } + await sbp('chelonia/contract/retain', contractID, { ephemeral: true }) + try { + if (shouldAddToUnreadMessages) { + sbp('gi.actions/identity/kv/addChatRoomUnreadMessage', { contractID, messageHash, createdHeight: height }) + } - // TODO: This needs to be done differently in the SW - const currentRoute = sbp('controller/router').history.current || '' - const isTargetChatroomCurrentlyActive = currentRoute.path.includes('/group-chat') && + /* + // TODO: This needs to be done differently in the SW + const currentRoute = sbp('controller/router').history.current || '' + const isTargetChatroomCurrentlyActive = currentRoute.path.includes('/group-chat') && rootGetters.currentChatRoomId === contractID // when the target chatroom is currently open/active on the browser, No need to send a notification. - if (isTargetChatroomCurrentlyActive) return // Skip notifications - // END TODO: This needs to be done differently in the SW + if (isTargetChatroomCurrentlyActive) return // Skip notifications + // END TODO: This needs to be done differently in the SW + */ - let title = `# ${chatRoomName}` - let icon - if (isGroupDM) { + let title = `# ${chatRoomName}` + let icon + if (isDM) { // NOTE: partner identity contract could not be synced yet - title = rootGetters.ourGroupDirectMessages[contractID].title - icon = rootGetters.ourGroupDirectMessages[contractID].picture - } - const path = `/group-chat/${contractID}` + const members = Object.keys(rootState[contractID].members) + const isDMToMyself = members.length === 1 && members[0] === identityContractID + const partners = members + .filter(memberID => memberID !== identityContractID) + .sort((p1, p2) => { + const p1JoinedDate = new Date(identityContractID.members[p1].joinedDate).getTime() + const p2JoinedDate = new Date(identityContractID.members[p2].joinedDate).getTime() + return p1JoinedDate - p2JoinedDate + }) + const lastJoinedPartner = isDMToMyself ? identityContractID : partners[partners.length - 1] - const chatNotificationSettings = rootGetters.chatNotificationSettings[contractID] || rootGetters.chatNotificationSettings.default - const { messageNotification, messageSound } = chatNotificationSettings - const shouldNotifyMessage = messageNotification === MESSAGE_NOTIFY_SETTINGS.ALL_MESSAGES || + title = isDMToMyself + ? rootGetters.userDisplayNameFromID(identityContractID) + : partners.map(cID => rootGetters.userDisplayNameFromID(cID)).join(', ') + icon = rootGetters.ourContactProfilesById[lastJoinedPartner]?.picture + } + const path = `/group-chat/${contractID}` + + const chatNotificationSettings = rootGetters.chatNotificationSettings[contractID] || rootGetters.chatNotificationSettings.default + const { messageNotification, messageSound } = chatNotificationSettings + const shouldNotifyMessage = messageNotification === MESSAGE_NOTIFY_SETTINGS.ALL_MESSAGES || (messageNotification === MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES && isDMOrMention) - const shouldSoundMessage = messageSound === MESSAGE_NOTIFY_SETTINGS.ALL_MESSAGES || + const shouldSoundMessage = messageSound === MESSAGE_NOTIFY_SETTINGS.ALL_MESSAGES || (messageSound === MESSAGE_NOTIFY_SETTINGS.DIRECT_MESSAGES && isDMOrMention) - shouldNotifyMessage && makeNotification({ - title, - body: messageType === MESSAGE_TYPES.TEXT ? swapMentionIDForDisplayname(text) : L('New message'), - icon, - path - }) - shouldSoundMessage && sbp('okTurtles.events/emit', MESSAGE_RECEIVE) + shouldNotifyMessage && makeNotification({ + title, + body: messageType === MESSAGE_TYPES.TEXT ? swapMentionIDForDisplayname(text) : L('New message'), + icon, + path + }) + // `MESSAGE_RECEIVE` should be forwarded to the tab + shouldSoundMessage && sbp('okTurtles.events/emit', MESSAGE_RECEIVE) + } finally { + await sbp('chelonia/contract/release', contractID, { ephemeral: true }) + } } export default messageReceivePostEffect diff --git a/frontend/model/notifications/nativeNotification.js b/frontend/model/notifications/nativeNotification.js index adbf411a69..a484dcdd9c 100644 --- a/frontend/model/notifications/nativeNotification.js +++ b/frontend/model/notifications/nativeNotification.js @@ -4,50 +4,115 @@ import sbp from '@sbp/sbp' // NOTE: since these functions don't modify contract state, it should // be safe to modify them without worrying about version conflicts. -export async function requestNotificationPermission (force: boolean = false): Promise { - if (typeof Notification === 'undefined') { - return null - } +const handler = (statuses: string[]) => { + // For some reason, Safari seems to always return `'prompt'` with + // `Notification.permission` being correct. + const granted = statuses.every(status => status === 'granted') || (statuses.every(status => status === 'prompt') && Notification.permission === 'granted') + sbp('state/vuex/commit', 'setNotificationEnabled', granted) +} - if (force || Notification.permission === 'default') { - try { - sbp('state/vuex/commit', 'setNotificationEnabled', await Notification.requestPermission() === 'granted') - } catch (e) { - console.error('requestNotificationPermission:', e.message) - return null +const fallbackChangeListener = () => { + if (!Notification.permission) return + let oldValue = Notification.permission + handler([oldValue]) + // This fallback runs when `navigator.permissions.query` isn't available + // It's meant to run while the tab is open to react to permission changes, + // and therefore it's not meant to be cleared + setInterval(() => { + const newValue = Notification.permission + if (oldValue !== newValue) { + oldValue = newValue + handler([oldValue]) } - } + }, 1000) +} + +export const setupNativeNotificationsListeners = () => { + // If the required APIs for native notifications aren't available, skip setup + if ( + typeof navigator !== 'object' || + typeof Notification !== 'function' || + typeof PushManager !== 'function' || + typeof ServiceWorker !== 'function' || + typeof navigator.serviceWorker !== 'object' + ) return - if (Notification.permission === 'granted') { - await sbp('service-worker/setup-push-subscription').catch((e) => { - console.error('Error setting up service worker', e) + if ( + typeof navigator.permissions === 'object' && + // $FlowFixMe[method-unbinding] + typeof navigator.permissions.query === 'function' + ) { + Promise.all([ + navigator.permissions.query({ name: 'notifications' }), + navigator.permissions.query({ name: 'push', userVisibleOnly: true }) + ]).then( + (statuses) => { + const states = statuses.map(status => status.state) + handler(states) + statuses[0].addEventListener('change', () => { + states[0] = statuses[0].state + handler(states) + }, false) + statuses[1].addEventListener('change', () => { + states[1] = statuses[1].state + handler(states) + }, false) + } + ).catch((e) => { + console.error('Error querying notifications permission', e) + fallbackChangeListener() }) + } else { + fallbackChangeListener() + } +} + +export async function requestNotificationPermission (): Promise { + if (typeof Notification !== 'function') { + return null } - return Notification.permission + try { + const result = await Notification.requestPermission() + sbp('state/vuex/commit', 'setNotificationEnabled', result === 'granted') + return result + } catch (e) { + console.error('requestNotificationPermission:', e.message) + return null + } } export function makeNotification ({ title, body, icon, path }: { title: string, body: string, icon?: string, path?: string -}): void { - if (Notification?.permission === 'granted' && sbp('state/vuex/settings').notificationEnabled) { +}): void | Promise { + if (typeof Notification !== 'function') return + // If not running on a SW + if (typeof WorkerGlobalScope !== 'function') { try { + // $FlowFixMe[incompatible-type] + if (navigator.vendor === 'Apple Computer, Inc.') { + throw new Error('Safari requires a service worker for the notification to be displayed') + } const notification = new Notification(title, { body, icon }) if (path) { notification.onclick = (event) => { sbp('controller/router').push({ path }).catch(console.warn) } } - } catch { - try { - // FIXME: find a cross-browser way to pass the 'path' parameter when the notification is clicked. - navigator.serviceWorker?.ready.then(registration => { - // $FlowFixMe - registration.showNotification(title, { body, icon }) - }).catch(console.warn) - } catch (error) { - console.error('makeNotification: ', error.message) - } + } catch (e) { + return navigator.serviceWorker?.ready.then(registration => { + // $FlowFixMe + return registration.showNotification(title, { body, icon, data: { path } }) + }).catch(console.warn) } + } else { + // If running in a SW + return self.clients.matchAll({ type: 'window' }).then((clientList) => { + // If the no window is focused, display a native notification + if (clientList.some(client => client.focused)) { + return + } + return self.registration.showNotification(title, { body, icon, data: { path } }).catch(console.warn) + }) } } diff --git a/frontend/model/notifications/selectors.js b/frontend/model/notifications/selectors.js index ebcede9f2a..cce0aa14f1 100644 --- a/frontend/model/notifications/selectors.js +++ b/frontend/model/notifications/selectors.js @@ -40,9 +40,6 @@ sbp('sbp/selectors/register', { type } const rootState = sbp('chelonia/rootState') - if (!rootState.notifications) { - rootState.notifications = { items: [], status: {} } - } if (rootState.notifications.items.some(item => item.hash === notification.hash)) { // We cannot throw here, as this code might be called from within a contract side effect. return console.error('[gi.notifications/emit] This notification is already in the store.', notification.hash) @@ -81,9 +78,6 @@ sbp('sbp/selectors/register', { }, 'gi.notifications/setNotificationStatus' (status) { const rootState = sbp('chelonia/rootState') - if (!rootState.notifications) { - rootState.notifications = { items: [], status: {} } - } rootState.notifications.status = status sbp('okTurtles.events/emit', CHELONIA_STATE_MODIFIED) sbp('okTurtles.events/emit', NOTIFICATION_STATUS_LOADED, status) diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index e878f21b6b..1174048e13 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -144,7 +144,7 @@ export default ({ scope: 'group' } }, - NEW_PROPOSAL (data: { groupID: string, creatorID: string, subtype: NewProposalType }) { + NEW_PROPOSAL (data: { groupID: string, creatorID: string, proposalHash: string, subtype: NewProposalType }) { const args = { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.creatorID}`, ...LTags('strong') @@ -173,9 +173,12 @@ export default ({ creatorID: data.creatorID, icon: iconMap[data.subtype], level: 'info', - linkTo: '/dashboard#proposals', subtype: data.subtype, - scope: 'group' + scope: 'group', + sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { + contractID: sbp('state/vuex/state').currentGroupId, + data: { proposalHash: data.proposalHash } + }] } }, PROPOSAL_EXPIRING (data: { proposalId: string, proposal: Object }) { @@ -200,11 +203,14 @@ export default ({ level: 'info', icon: 'exclamation-triangle', scope: 'group', - linkTo: '/dashboard#proposals', - data: { proposalId: data.proposalId } + data: { proposalId: data.proposalId }, + sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { + contractID: sbp('state/vuex/state').currentGroupId, + data: { proposalHash: data.proposalId } + }] } }, - PROPOSAL_CLOSED (data: { proposal: Object }) { + PROPOSAL_CLOSED (data: { proposal: Object, proposalHash: string }) { const { creatorID, status, type, options } = getProposalDetails(data.proposal) const statusMap = { @@ -244,8 +250,11 @@ export default ({ body: bodyTemplateMap[type], icon: statusMap[status].icon, level: statusMap[status].level, - linkTo: '/dashboard#proposals', - scope: 'group' + scope: 'group', + sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { + contractID: sbp('state/vuex/state').currentGroupId, + data: { proposalHash: data.proposalHash } + }] } }, PAYMENT_RECEIVED (data: { creatorID: string, amount: string, paymentHash: string }) { diff --git a/frontend/model/notifications/vuexModule.js b/frontend/model/notifications/vuexModule.js index c5b9f6f067..51def7fd2f 100644 --- a/frontend/model/notifications/vuexModule.js +++ b/frontend/model/notifications/vuexModule.js @@ -4,7 +4,6 @@ import sbp from '@sbp/sbp' import Vue from 'vue' import { cloneDeep } from '~/frontend/model/contracts/shared/giLodash.js' import * as keys from './mutationKeys.js' -import './selectors.js' import { MAX_AGE_READ, MAX_AGE_UNREAD } from './storageConstants.js' import type { Notification } from './types.flow.js' import { age, compareOnTimestamp, isNew, isOlder } from './utils.js' diff --git a/frontend/model/settings/vuexModule.js b/frontend/model/settings/vuexModule.js index fd7f1114dc..c0aeca86fc 100644 --- a/frontend/model/settings/vuexModule.js +++ b/frontend/model/settings/vuexModule.js @@ -26,12 +26,12 @@ const defaultTheme = 'system' const defaultColor: string = checkSystemColor() export const defaultSettings = { - appLogsFilter: (((process.env.NODE_ENV === 'development' || new URLSearchParams(window.location.search).get('debug')) + appLogsFilter: (((process.env.NODE_ENV === 'development' || new URLSearchParams(location.search).get('debug')) ? ['error', 'warn', 'info', 'debug', 'log'] : ['error', 'warn', 'info']): string[]), fontSize: 16, increasedContrast: false, - notificationEnabled: true, + notificationEnabled: false, reducedMotion: false, theme: defaultTheme, themeColor: defaultColor @@ -70,6 +70,16 @@ const mutations = { state.increasedContrast = isChecked }, setNotificationEnabled (state, enabled) { + if (state.notificationEnabled !== enabled) { + // We do this call to `service-worker` here to avoid DRY violations. + // The intent is creating a subscription if none exists and letting the + // server know of the subscription + sbp('service-worker/setup-push-subscription').catch(e => { + // The parent `if` branch should prevent infinite loops + sbp('state/vuex/commit', 'setNotificationEnabled', false) + console.error('[setNotificationEnabled] Error calling service-worker/setup-push-subscription', e) + }) + } state.notificationEnabled = enabled }, setReducedMotion (state, isChecked) { diff --git a/frontend/model/state.js b/frontend/model/state.js index d7f2b6c12a..42d2e72e7a 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -4,21 +4,15 @@ // state) per: http://vuex.vuejs.org/en/intro.html import sbp from '@sbp/sbp' -import { L } from '@common/common.js' import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.js' import { doesGroupAnyoneCanJoinNeedUpdating } from '@model/contracts/shared/functions.js' -import { INVITE_STATUS } from '~/shared/domains/chelonia/constants.js' import { LOGOUT } from '~/frontend/utils/events.js' import Vue from 'vue' import Vuex from 'vuex' -import { PROFILE_STATUS, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' -import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' +import { PROFILE_STATUS } from '@model/contracts/shared/constants.js' import { cloneDeep, debounce } from '@model/contracts/shared/giLodash.js' -import { unadjustedDistribution, adjustedDistribution } from '@model/contracts/shared/distribution/distribution.js' import { applyStorageRules } from '~/frontend/model/notifications/utils.js' -import chatroomGetters from './contracts/shared/getters/chatroom.js' -import groupGetters from './contracts/shared/getters/group.js' -import identityGetters from './contracts/shared/getters/identity.js' +import getters from './getters.js' // Vuex modules. import notificationModule from '~/frontend/model/notifications/vuexModule.js' @@ -50,6 +44,7 @@ const contractUpdate = (initialState: Object, updateFn: (state: Object, contract // This function is called when the set of subscribed contracts is modified const modifiedHandler = (_, { added }) => { // Wait for the added contracts to be ready, then call the update function + if (!added.length) return sbp('chelonia/contract/wait', added).then(() => { const state = sbp('state/vuex/state') wrappedUpdateFn(state, added) @@ -101,27 +96,6 @@ if (window.matchMedia) { }) } -const checkedUsername = (state: Object, username: string, userID: string) => { - if (username && state.namespaceLookups[username] === userID) { - return username - } -} - -// Find the 'anyone can join' invite ID. Since there could be multiple, and some -// of those could have exipred, we need a for loop -const anyoneCanJoinInviteId = (invites: Object, getters: Object): ?string => - Object.keys(invites).find(invite => - // First, we want 'anyone can join' invites - invites[invite].creatorID === INVITE_INITIAL_CREATOR && - // and that haven't been revoked - getters.currentGroupState._vm.invites[invite].status === INVITE_STATUS.VALID && - // and that haven't expired (using negative logic because expires could be - // undefined for non expiring-invites) - !(getters.currentGroupState._vm.invites[invite].expires < Date.now()) && - // and that that haven't been entirely used up - !(getters.currentGroupState._vm.invites[invite].quantity <= 0) - ) - const reactiveDate = Vue.observable({ date: new Date() }) setInterval(function () { // We want the getters to recalculate all of the payments within 1 minute of us entering a new period. @@ -323,467 +297,16 @@ const mutations = { noop () {} } -// https://vuex.vuejs.org/en/getters.html -// https://vuex.vuejs.org/en/modules.html -const getters = { - // !! IMPORTANT !! - // - // We register pure Vuex getters here, but later on at the bottom of this file, - // we will also import into Vuex the contract getters so that they can be reused - // without having to be redefined. This is possible because Chelonia contract getters - // are designed to be compatible with Vuex getters. - // - // We will use the getters 'currentGroupState', 'currentIdentityState', and - // 'currentChatRoomState' as a "bridge" between the contract getters and Vuex. - // - // This makes it possible for the getters inside of contracts to refer to each - // specific contractID instance, while the Vuex version of those getters that - // are imported at the bottom of this file (in the listener for CONTRACT_REGISTERED - // will reference the state for the specific contractID for either the current group, - // the current user identity contract, or the current chatroom we're looking at. - // - // For getters that get data from only contract state, write them - // under the 'getters' key of the object passed to 'chelonia/defineContract'. - // See for example: frontend/model/contracts/group.js - // - // Again, for convenience, we've defined the same getter, `currentGroupState`, - // twice, so that we can reuse the same getter definitions both here with Vuex, - // and inside of the contracts (e.g. in group.js). - // - // The 'currentGroupState' here is based off the value of `state.currentGroupId`, - // a user preference that does not exist in the group contract state. - currentGroupState (state) { - return state[state.currentGroupId] || {} // avoid "undefined" vue errors at inoportune times - }, - currentIdentityState (state) { - return (state.loggedIn && state[state.loggedIn.identityContractID]) || {} - }, - ourUsername (state, getters) { - return state.loggedIn && getters.usernameFromID(state.loggedIn.identityContractID) - }, - ourPreferences (state) { - return state.preferences - }, - ourProfileActive (state, getters) { - return getters.profileActive(getters.ourIdentityContractId) - }, - ourPendingAccept (state, getters) { - return getters.pendingAccept(getters.ourIdentityContractId) - }, - ourGroupProfile (state, getters) { - return getters.groupProfile(getters.ourIdentityContractId) - }, - ourUserDisplayName (state, getters) { - // TODO - refactor Profile and Welcome and any other component that needs this - const userContract = getters.currentIdentityState || {} - return userContract.attributes?.displayName || getters.ourUsername || getters.ourIdentityContractId - }, - ourIdentityContractId (state) { - return state.loggedIn && state.loggedIn.identityContractID - }, - currentGroupLastLoggedIn (state) { - return state.lastLoggedIn[state.currentGroupId] || {} - }, - // NOTE: since this getter is written using `getters.ourUsername`, which is based - // on vuexState.loggedIn (a user preference), we cannot use this getter - // into group.js - ourContributionSummary (state, getters) { - const groupProfiles = getters.groupProfiles - const ourIdentityContractId = getters.ourIdentityContractId - const ourGroupProfile = getters.ourGroupProfile - - if (!ourGroupProfile || !ourGroupProfile.incomeDetailsType) { - return {} - } - - const doWeNeedIncome = ourGroupProfile.incomeDetailsType === 'incomeAmount' - const distribution = getters.groupIncomeDistribution - - const nonMonetaryContributionsOf = (memberID) => groupProfiles[memberID].nonMonetaryContributions || [] - - return { - givingMonetary: (() => { - if (doWeNeedIncome) { return null } - const who = [] - const total = distribution - .filter(p => p.fromMemberID === ourIdentityContractId) - .reduce((acc, payment) => { - who.push(getters.userDisplayNameFromID(payment.toMemberID)) - return acc + payment.amount - }, 0) - - return { who, total, pledged: ourGroupProfile.pledgeAmount } - })(), - receivingMonetary: (() => { - if (!doWeNeedIncome) { return null } - const needed = getters.groupSettings.mincomeAmount - ourGroupProfile.incomeAmount - const who = [] - const total = distribution - .filter(p => p.toMemberID === ourIdentityContractId) - .reduce((acc, payment) => { - who.push(getters.userDisplayNameFromID(payment.fromMemberID)) - return acc + payment.amount - }, 0) - - return { who, total, needed } - })(), - receivingNonMonetary: (() => { - const listWho = Object.keys(groupProfiles) - .filter(memberID => memberID !== ourIdentityContractId && nonMonetaryContributionsOf(memberID).length > 0) - const listWhat = listWho.reduce((contr, memberID) => { - const displayName = getters.userDisplayNameFromID(memberID) - const userContributions = nonMonetaryContributionsOf(memberID) - - userContributions.forEach((what) => { - const contributionIndex = contr.findIndex(c => c.what === what) - contributionIndex >= 0 - ? contr[contributionIndex].who.push(displayName) - : contr.push({ who: [displayName], what }) - }) - return contr - }, []) - - return listWho.length > 0 ? { what: listWhat, who: listWho } : null - })(), - givingNonMonetary: (() => { - const contributions = ourGroupProfile.nonMonetaryContributions - - return contributions.length > 0 ? contributions : null - })() - } - }, - usernameFromID (state, getters) { - return (userID) => { - const profile = getters.ourContactProfilesById[userID] - return profile?.username || state.reverseNamespaceLookups[userID] || userID - } - }, - userDisplayNameFromID (state, getters) { - return (userID) => { - if (userID === getters.ourIdentityContractId) { - return getters.ourUserDisplayName - } - const profile = getters.ourContactProfilesById[userID] - return profile?.displayName || profile?.username || state.reverseNamespaceLookups[userID] || userID - } - }, - // this getter gets recomputed automatically according to the setInterval on reactiveDate - currentPaymentPeriod (state, getters) { - return getters.periodStampGivenDate(reactiveDate.date) - }, - thisPeriodPaymentInfo (state, getters) { - return getters.groupPeriodPayments[getters.currentPaymentPeriod] - }, - latePayments (state, getters) { - const periodPayments = getters.groupPeriodPayments - if (Object.keys(periodPayments).length === 0) return - const ourIdentityContractId = getters.ourIdentityContractId - const pPeriod = getters.periodBeforePeriod(getters.currentPaymentPeriod) - const pPayments = periodPayments[pPeriod] - if (pPayments) { - return pPayments.lastAdjustedDistribution.filter(todo => todo.fromMemberID === ourIdentityContractId) - } - }, - // used with graphs like those in the dashboard and in the income details modal - groupIncomeDistribution (state, getters) { - return unadjustedDistribution({ - haveNeeds: getters.haveNeedsForThisPeriod(getters.currentPaymentPeriod), - minimize: false - }) - }, - // adjusted version of groupIncomeDistribution, used by the payments system - groupIncomeAdjustedDistribution (state, getters) { - const paymentInfo = getters.thisPeriodPaymentInfo - if (paymentInfo && paymentInfo.lastAdjustedDistribution) { - return paymentInfo.lastAdjustedDistribution - } else { - const period = getters.currentPaymentPeriod - return adjustedDistribution({ - distribution: unadjustedDistribution({ - haveNeeds: getters.haveNeedsForThisPeriod(period), - minimize: getters.groupSettings.minimizeDistribution - }), - payments: getters.paymentsForPeriod(period), - dueOn: getters.dueDateForPeriod(period) - }) - } - }, - ourPaymentsSentInPeriod (state, getters) { - return (period) => { - const periodPayments = getters.groupPeriodPayments - if (Object.keys(periodPayments).length === 0) return - const payments = [] - const thisPeriodPayments = periodPayments[period] - const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom - if (paymentsFrom) { - const ourIdentityContractId = getters.ourIdentityContractId - const allPayments = getters.currentGroupState.payments - for (const toMemberID in paymentsFrom[ourIdentityContractId]) { - for (const paymentHash of paymentsFrom[ourIdentityContractId][toMemberID]) { - const { data, meta, height } = allPayments[paymentHash] - - payments.push({ hash: paymentHash, height, data, meta, amount: data.amount, period }) - } - } - } - return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) - } - }, - ourPaymentsReceivedInPeriod (state, getters) { - return (period) => { - const periodPayments = getters.groupPeriodPayments - if (Object.keys(periodPayments).length === 0) return - const payments = [] - const thisPeriodPayments = periodPayments[period] - const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom - if (paymentsFrom) { - const ourIdentityContractId = getters.ourIdentityContractId - const allPayments = getters.currentGroupState.payments - for (const fromMemberID in paymentsFrom) { - for (const toMemberID in paymentsFrom[fromMemberID]) { - if (toMemberID === ourIdentityContractId) { - for (const paymentHash of paymentsFrom[fromMemberID][toMemberID]) { - const { data, meta, height } = allPayments[paymentHash] - - payments.push({ hash: paymentHash, height, data, meta, amount: data.amount }) - } - } - } - } - } - return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) - } - }, - ourPayments (state, getters) { - const periodPayments = getters.groupPeriodPayments - if (Object.keys(periodPayments).length === 0) return - const ourIdentityContractId = getters.ourIdentityContractId - const cPeriod = getters.currentPaymentPeriod - const pPeriod = getters.periodBeforePeriod(cPeriod) - const currentSent = getters.ourPaymentsSentInPeriod(cPeriod) - const previousSent = getters.ourPaymentsSentInPeriod(pPeriod) - const currentReceived = getters.ourPaymentsReceivedInPeriod(cPeriod) - const previousReceived = getters.ourPaymentsReceivedInPeriod(pPeriod) - - // TODO: take into account pending payments that have been sent but not yet completed - const todo = () => { - return getters.groupIncomeAdjustedDistribution.filter(p => p.fromMemberID === ourIdentityContractId) - } - - return { - sent: [...currentSent, ...previousSent], - received: [...currentReceived, ...previousReceived], - todo: todo() - } - }, - ourPaymentsSummary (state, getters) { - const isNeeder = getters.ourGroupProfile.incomeDetailsType === 'incomeAmount' - const ourIdentityContractId = getters.ourIdentityContractId - const isOurPayment = (payment) => { - return isNeeder ? payment.toMemberID === ourIdentityContractId : payment.fromMemberID === ourIdentityContractId - } - const sumUpAmountReducer = (acc, payment) => acc + payment.amount - const cPeriod = getters.currentPaymentPeriod - const ourAdjustedPayments = getters.groupIncomeAdjustedDistribution.filter(isOurPayment) - const receivedOrSent = isNeeder - ? getters.ourPaymentsReceivedInPeriod(cPeriod) - : getters.ourPaymentsSentInPeriod(cPeriod) - - const markedAsNotReceived = receivedOrSent.filter(payment => payment.data.status === PAYMENT_NOT_RECEIVED) - const markedAsNotReceivedTotal = markedAsNotReceived.reduce(sumUpAmountReducer, 0) - - const paymentsTotal = ourAdjustedPayments.length + receivedOrSent.length - const nonLateAdjusted = ourAdjustedPayments.filter((p) => !p.isLate) - const paymentsDone = paymentsTotal - nonLateAdjusted.length - markedAsNotReceived.length - const hasPartials = ourAdjustedPayments.some(p => p.partial) - const amountDone = receivedOrSent.reduce(sumUpAmountReducer, 0) - markedAsNotReceivedTotal - const amountLeft = ourAdjustedPayments.reduce((acc, payment) => acc + payment.amount, 0) + markedAsNotReceivedTotal - const amountTotal = amountDone + amountLeft - return { - paymentsDone, - hasPartials, - paymentsTotal, - amountDone, - amountTotal - } - }, - currentWelcomeInvite (state, getters) { - const invites = getters.currentGroupState.invites - const inviteId = anyoneCanJoinInviteId(invites, getters) - const expires = getters.currentGroupState._vm.invites[inviteId].expires - return { inviteId, expires } - }, - // list of group names and contractIDs - groupsByName (state, getters) { - const identityContractID = getters.ourIdentityContractId - const groups = state[identityContractID]?.groups - if (!groups) return [] - // The code below was originally Object.entries(...) but changed to .keys() - // due to the same flow issue as https://github.com/facebook/flow/issues/5838 - // we return event pending groups that we haven't finished joining so that we are not stuck - // on the /pending-approval page if we are part of another working group already - return Object.entries(groups) - // $FlowFixMe[incompatible-use] - .filter(([, { hasLeft }]) => !hasLeft) - .map(([contractID]) => ({ groupName: state[contractID]?.settings?.groupName || L('Pending'), contractID, active: state[contractID]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE })) - }, - profilesByGroup (state, getters) { - return groupID => { - const profiles = {} - if (state.contracts[groupID]?.type !== 'gi.contracts/group') { - return profiles - } - const groupProfiles = state[groupID].profiles || {} - for (const member in groupProfiles) { - const profile = groupProfiles[member] - if (profile.status === PROFILE_STATUS.ACTIVE) { - profiles[member] = profile - } - } - return profiles - } - }, - groupMembersSorted (state, getters) { - const profiles = getters.currentGroupState.profiles - if (!profiles || !profiles[getters.ourIdentityContractId]) return [] - const weJoinedHeight = profiles[getters.ourIdentityContractId].joinedHeight - const isNewMember = (memberID) => { - if (memberID === getters.ourIdentityContractId) { return false } - const memberProfile = profiles[memberID] - if (!memberProfile) return false - const memberJoinedHeight = memberProfile.joinedHeight - const memberJoinedMs = new Date(memberProfile.joinedDate).getTime() - const joinedAfterUs = weJoinedHeight < memberJoinedHeight - return joinedAfterUs && Date.now() - memberJoinedMs < 604800000 // joined less than 1w (168h) ago. - } - - const groupMembersPending = getters.groupMembersPending - - // $FlowFixMe[method-unbinding] - return [groupMembersPending, getters.groupProfiles].flatMap(Object.keys) - .filter(memberID => getters.groupProfiles[memberID] || - !(getters.groupMembersPending[memberID].expires < Date.now())) - .map(memberID => { - const { contractID, displayName, username } = getters.globalProfile(memberID) || groupMembersPending[memberID] || (getters.groupProfiles[memberID] ? { contractID: memberID } : {}) - return { - id: memberID, // common unique ID: it can be either the contract ID or the invite key - contractID, - username, - displayName: displayName || username || memberID, - invitedBy: getters.groupMembersPending[memberID], - isNew: isNewMember(memberID) - } - }) - .sort((userA, userB) => { - const nameA = userA.displayName.normalize().toUpperCase() - const nameB = userB.displayName.normalize().toUpperCase() - // Show pending members first - if (userA.invitedBy && !userB.invitedBy) { return -1 } - if (!userA.invitedBy && userB.invitedBy) { return 1 } - // Then new members... - if (userA.isNew && !userB.isNew) { return -1 } - if (!userA.isNew && userB.isNew) { return 1 } - // and sort them all by A-Z - return nameA < nameB ? -1 : 1 - }) - }, - groupProposals (state, getters) { - return contractID => state[contractID]?.proposals - }, - globalProfile (state, getters) { - // get profile from username who is part of current group - return memberID => { - return getters.ourContactProfilesById[memberID] - } - }, - ourContactProfilesByUsername (state, getters) { - const profiles = {} - Object.keys(state.contracts) - .filter(contractID => state.contracts[contractID].type === 'gi.contracts/identity') - .forEach(contractID => { - const attributes = state[contractID].attributes - if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts - const username = checkedUsername(state, attributes.username, contractID) - if (!username) return - profiles[username] = { - ...attributes, - username, - contractID - } - } - }) - return profiles - }, - ourContactProfilesById (state, getters) { - const profiles = {} - Object.keys(state.contracts) - .filter(contractID => state.contracts[contractID].type === 'gi.contracts/identity') - .forEach(contractID => { - if (!state[contractID]) { - console.warn('[ourContactProfilesById] Missing state', contractID) - return - } - const attributes = state[contractID].attributes - if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts - const username = checkedUsername(state, attributes.username, contractID) - profiles[contractID] = { - ...attributes, - username, - contractID - } - } - }) - // For consistency, add users that were known in the past (since those - // contracts will be removed). This keeps mentions working in existing - // devices - Object.keys(state.reverseNamespaceLookups).forEach((contractID) => { - if (profiles[contractID]) return - profiles[contractID] = { - username: state.reverseNamespaceLookups[contractID], - contractID - } - }) - return profiles - }, - currentGroupContactProfilesById (state, getters) { - const currentGroupProfileIds = Object.keys(getters.currentGroupState.profiles || {}) - const filtered = {} - - for (const identityContractID in getters.ourContactProfilesById) { - if (currentGroupProfileIds.includes(identityContractID)) { - filtered[identityContractID] = getters.ourContactProfilesById[identityContractID] - } - } - return filtered - }, - ourContactsById (state, getters) { - return Object.keys(getters.ourContactProfilesById) - .sort((userIdA, userIdB) => { - const nameA = ((getters.ourContactProfilesById[userIdA].displayName)) || getters.ourContactProfilesById[userIdA].username || userIdA - const nameB = ((getters.ourContactProfilesById[userIdB].displayName)) || getters.ourContactProfilesById[userIdB].username || userIdB - return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 - }) - }, - ourContactsByUsername (state, getters) { - return Object.keys(getters.ourContactProfilesByUsername) - .sort((usernameA, usernameB) => { - const nameA = getters.ourContactProfilesByUsername[usernameA].displayName || usernameA - const nameB = getters.ourContactProfilesByUsername[usernameB].displayName || usernameB - return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 - }) - }, - seenWelcomeScreen (state, getters) { - return getters.ourProfileActive && getters.currentIdentityState?.groups?.[state.currentGroupId]?.seenWelcomeScreen - }, - ...chatroomGetters, - ...groupGetters, - ...identityGetters -} - const store: any = new Vuex.Store({ state: cloneDeep(initialState), mutations, - getters, + getters: { + ...getters, + // this getter gets recomputed automatically according to the setInterval on reactiveDate + currentPaymentPeriod (state, getters) { + return getters.periodStampGivenDate(reactiveDate.date) + } + }, modules: { notifications: notificationModule, settings: settingsModule, diff --git a/frontend/model/swCaptureLogs.js b/frontend/model/swCaptureLogs.js new file mode 100644 index 0000000000..44baeff041 --- /dev/null +++ b/frontend/model/swCaptureLogs.js @@ -0,0 +1,87 @@ +import sbp from '@sbp/sbp' +import { debounce } from '@model/contracts/shared/giLodash.js' +import { CAPTURED_LOGS, SET_APP_LOGS_FILTER } from '~/frontend/utils/events.js' +import { MAX_LOG_ENTRIES } from '~/frontend/utils/constants.js' +import { createLogger } from './logger.js' +import logServer from './logServer.js' + +/* + - giConsole/[username]/entries - the stored log entries. + - giConsole/[username]/config - the logger config used. +*/ + +const config = { + maxEntries: MAX_LOG_ENTRIES, + source: 'sw' +} +const originalConsole = self.console + +// These are initialized in `captureLogsStart()`. +let logger: Object = null +let identityContractID: string = '' + +// A default storage backend using `IndexedDB`. +const getItem = (key: string): Promise => sbp('gi.db/logs/load', `giConsole/${identityContractID}/${key}`) +const removeItem = (key: string): Promise => sbp('gi.db/logs/delete', `giConsole/${identityContractID}/${key}`) +const setItem = (key: string, value: any): Promise => { + return sbp('gi.db/logs/save', `giConsole/${identityContractID}/${key}`, typeof value === 'string' ? value : JSON.stringify(value)) +} + +async function captureLogsStart (userLogged: string) { + identityContractID = userLogged + + logger = await createLogger(config, { getItem, removeItem, setItem }) + + // Save the new config. + await setItem('config', config) + + // TODO: Get this dynamically + logger.setAppLogsFilter((((process.env.NODE_ENV === 'development' || new URLSearchParams(location.search).get('debug')) + ? ['error', 'warn', 'info', 'debug', 'log'] + : ['error', 'warn', 'info']): string[])) + + // Subscribe to `swLogsFilter` changes. + sbp('okTurtles.events/on', SET_APP_LOGS_FILTER, logger.setAppLogsFilter) + + // Overwrite the original console. + self.console = logger.console + + originalConsole.log('Starting to capture logs of type:', logger.swLogsFilter) +} + +async function captureLogsPause ({ wipeOut }: { wipeOut: boolean }): Promise { + if (wipeOut) { await clearLogs() } + sbp('okTurtles.events/off', SET_APP_LOGS_FILTER) + console.log('captureLogs paused') + // Restore original console behavior. + self.console = originalConsole +} + +async function clearLogs () { + await logger?.clear() +} + +// In the SW, there's no event to detect when the SW is about to terminate. As +// a result, we must save logs on every saved entry. However, this wouldn't be +// very performant, so we debounce the save instead. +sbp('okTurtles.events/on', CAPTURED_LOGS, debounce(() => { + logger?.save().catch(e => { + console.error('Error saving logs during CAPTURED_LOGS event handler', e) + }) +}, 1000)) + +// Enable logging to the server +logServer(originalConsole) + +export default (sbp('sbp/selectors/register', { + 'swLogs/get' () { return logger?.entries.toArray() ?? [] }, + async 'swLogs/save' () { await logger?.save() }, + 'swLogs/pauseCapture': captureLogsPause, + 'swLogs/startCapture': captureLogsStart, + async 'swLogs/clearLogs' (userID) { + const savedID = identityContractID + identityContractID = userID + try { await clearLogs() } catch {} + identityContractID = savedID + } +}): string[]) diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index 82576a7ae3..625c3880fb 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -10,12 +10,12 @@ import { groupContractsByType, syncContractsInOrder } from './controller/actions import { PUBSUB_INSTANCE } from './controller/instance-keys.js' import manifests from './model/contracts/manifests.json' import { SETTING_CHELONIA_STATE, SETTING_CURRENT_USER } from './model/database.js' -import { CHATROOM_USER_STOP_TYPING, CHATROOM_USER_TYPING, CHELONIA_STATE_MODIFIED, KV_EVENT, LOGIN_COMPLETE, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED } from './utils/events.js' +import { CHATROOM_USER_STOP_TYPING, CHATROOM_USER_TYPING, CHELONIA_STATE_MODIFIED, KV_EVENT, LOGIN_COMPLETE, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SERIOUS_ERROR } from './utils/events.js' // This function is tasked with most common tasks related to setting up Chelonia // for Group Income. If Chelonia is running in a service worker, the service // worker should call this function. On the other hand, if Chelonia is running -// in the browsing context, the service worker is the one that should call this +// in the browsing context, the browsing context is the one that should call this // function. const setupChelonia = async (): Promise<*> => { // Load Chelonia state (this needs to be done in the SW when Chelonia is @@ -55,16 +55,22 @@ const setupChelonia = async (): Promise<*> => { const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) if (!identityContractID) return await sbp('chelonia/reset', cheloniaState) + // [SW] If there's an active session, we need to start capture now + if (typeof WorkerGlobalScope === 'function') { + await sbp('swLogs/startCapture', identityContractID) + } }) // this is to ensure compatibility between frontend and test/backend.test.js - sbp('okTurtles.data/set', 'API_URL', window.location.origin) + sbp('okTurtles.data/set', 'API_URL', self.location.origin) // Used in 'chelonia/configure' hooks to emit an error notification. const errorNotification = (activity: string, error: Error, message: GIMessage) => { sbp('gi.notifications/emit', 'CHELONIA_ERROR', { createdDate: new Date().toISOString(), activity, error, message }) // Since a runtime error just occured, we likely want to persist app logs to local storage now. - sbp('appLogs/save') + sbp('appLogs/save').catch(e => { + console.error('Error saving logs during error notification', e) + }) } let logoutInProgress = false @@ -131,14 +137,14 @@ const setupChelonia = async (): Promise<*> => { exposedGlobals: { // note: needs to be written this way and not simply "Notification" // because that breaks on mobile where Notification is undefined - Notification: window.Notification + Notification: self.Notification } } }, hooks: { handleEventError: (e: Error, message: GIMessage) => { if (e.name === 'ChelErrorUnrecoverable') { - sbp('gi.ui/seriousErrorBanner', e) + sbp('okTurtles.events/emit', SERIOUS_ERROR, e) } if (sbp('okTurtles.data/get', 'sideEffectError') !== message.hash()) { // Avoid duplicate notifications for the same message. @@ -158,15 +164,13 @@ const setupChelonia = async (): Promise<*> => { errorNotification('process', e, message) }, sideEffectError: (e: Error, message: GIMessage) => { - sbp('gi.ui/seriousErrorBanner', e) + sbp('okTurtles.events/emit', SERIOUS_ERROR, e) sbp('okTurtles.data/set', 'sideEffectError', message.hash()) errorNotification('sideEffect', e, message) } } }) - // TODO: This needs to be relayed from the originating tab to the SW. Maybe - // creating a selector would be more appropriate. sbp('okTurtles.events/on', LOGIN_COMPLETE, () => { const state = sbp('chelonia/rootState') if (!state.loggedIn) { @@ -297,4 +301,19 @@ const setupChelonia = async (): Promise<*> => { }) } -export default setupChelonia +// This implements a 'singleton promise' or 'lazy intialization' of setupChelonia. +// The idea is that `setupChelonia` be called only once, regardless of how many +// actual invocations actually happen (unless the last invocation resolved +// and rejected) +export default ((() => { + let promise + return () => { + if (!promise) { + promise = setupChelonia().catch((e) => { + promise = undefined // Reset on error + throw e // Re-throw the error + }) + } + return promise + } +})(): () => Promise) diff --git a/frontend/utils/events.js b/frontend/utils/events.js index d2806488c8..a27b18334b 100644 --- a/frontend/utils/events.js +++ b/frontend/utils/events.js @@ -66,3 +66,10 @@ export const CHELONIA_STATE_MODIFIED = 'chelonia-state-modified' export const NOTIFICATION_EMITTED = 'notification-emitted' export const NOTIFICATION_REMOVED = 'notification-removed' export const NOTIFICATION_STATUS_LOADED = 'notification-status-loaded' + +export const NEW_CHATROOM_UNREAD_POSITION = 'new-chatroom-unread-position' +export const NEW_LAST_LOGGED_IN = 'new-last-logged-in' +export const NEW_UNREAD_MESSAGES = 'new-unread-messages' +export const NEW_PREFERENCES = 'new-preferences' + +export const SERIOUS_ERROR = 'serious-error' diff --git a/frontend/utils/isPwa.js b/frontend/utils/isPwa.js new file mode 100644 index 0000000000..f42d0c8a0e --- /dev/null +++ b/frontend/utils/isPwa.js @@ -0,0 +1,14 @@ +export default ((() => { + let isPwa + + return () => { + if (isPwa == null) { + isPwa = + window.matchMedia('(display-mode: standalone) or (display-mode: window-controls-overlay)').matches || + // $FlowFixMe[prop-missing] + navigator.standalone + } + + return isPwa + } +})(): () => boolean) diff --git a/frontend/views/components/PageSection.vue b/frontend/views/components/PageSection.vue index 355e81525a..cdb489e23d 100644 --- a/frontend/views/components/PageSection.vue +++ b/frontend/views/components/PageSection.vue @@ -51,5 +51,6 @@ export default ({ align-items: center; justify-content: space-between; flex-wrap: wrap; + row-gap: 1rem; // There should be a space between the title and the cta when they become two rows. } diff --git a/frontend/views/components/sounds/Background.vue b/frontend/views/components/sounds/Background.vue index 9cda076973..2e47a68ba3 100644 --- a/frontend/views/components/sounds/Background.vue +++ b/frontend/views/components/sounds/Background.vue @@ -7,6 +7,7 @@