From 307baf6cde4bdc5e35025e786f0e07693140f9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 15 Jan 2025 21:33:15 +0200 Subject: [PATCH] Basic notification support (#2459) * Basic notification support * Notification template updates * Add groupID and plaintext display name * Set the correct group ID when clicking on notifications, etc. * Fallback Notification Co-authored-by: Greg Slepak * Comment for sbp-type message * Notification icons * server-side push recurring inverval notification * More generic periodic notifications * `ForGroup` pattern * Improvements and bugfixes * Update `mainNotificationsMixin` * WIP * WIP * Emit notifications on recurring push event * Fixes WIP * Revert changes to NotificationCard.vue * Wait for queue invocation to be empty * Remove unnecessary setTimeout * Periodic sync events * Fix types * Bugfix * Feedback * Feedback * Revised getters * `ForGroup` improvements * Explain why not ID * Fix flow error * Minor improvements * Increased resiliency to group join errors * Bugfixes from feedback * Error handling * Use setTimeout for re-attempts * Feedback * Add throw * Add comment to PendingApproval.vue * Fallback to group picture --------- Co-authored-by: Greg Slepak --- backend/server.js | 19 +- frontend/controller/actions/group.js | 180 ++++++++------ frontend/controller/actions/index.js | 10 +- frontend/controller/app/group.js | 43 ++++ frontend/controller/service-worker.js | 30 +++ frontend/controller/serviceworkers/push.js | 18 +- .../controller/serviceworkers/sw-primary.js | 126 ++++++++-- frontend/model/contracts/group.js | 9 - .../model/contracts/shared/getters/group.js | 218 ++++++++++++----- frontend/model/getters.js | 125 ++++++---- frontend/model/notifications/getters.js | 81 +++++++ .../notifications/mainNotificationsMixin.js | 167 +------------ .../mainPeriodicNotificationEntries.js | 225 ++++++++++++++++++ .../notifications/messageReceivePostEffect.js | 2 + .../model/notifications/nativeNotification.js | 27 ++- .../notifications/periodicNotifications.js | 168 ++++++++++--- frontend/model/notifications/templates.js | 206 +++++++++++++--- frontend/model/notifications/types.flow.js | 4 + frontend/model/notifications/vuexModule.js | 83 +------ frontend/model/state.js | 20 +- frontend/setupChelonia.js | 19 +- frontend/views/pages/Join.vue | 8 +- frontend/views/pages/PendingApproval.vue | 26 ++ shared/domains/chelonia/chelonia.js | 14 +- 24 files changed, 1297 insertions(+), 531 deletions(-) create mode 100644 frontend/model/notifications/getters.js create mode 100644 frontend/model/notifications/mainPeriodicNotificationEntries.js diff --git a/backend/server.js b/backend/server.js index 2fa1156089..1302033dfa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,7 +17,7 @@ import { createPushErrorResponse, createServer } from './pubsub.js' -import { addChannelToSubscription, deleteChannelFromSubscription, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js' +import { addChannelToSubscription, deleteChannelFromSubscription, postEvent, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' @@ -378,3 +378,20 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { console.info('Backend server running at:', hapi.info.uri) sbp('okTurtles.events/emit', SERVER_RUNNING, hapi) })() + +// Recurring task to send messages to push clients (for periodic notifications) +setInterval(() => { + const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + // Notification text + const notification = JSON.stringify({ type: 'recurring' }) + // Find push subscriptions that do _not_ have a WS open. This means clients + // that are 'asleep' and that might be woken up by the push event + Object.values(pubsub.pushSubscriptions || {}) + .filter((pushSubscription: Object) => pushSubscription.sockets.size === 0) + .forEach((pushSubscription: Object) => { + postEvent(pushSubscription, notification).catch((e) => { + console.warn(e, 'Error sending recurring message to web push client', pushSubscription.id) + }) + }) +// Repeat every 12 hours +}, 12 * 60 * 60 * 1000) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 62f9442709..3e56857e50 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -1,6 +1,6 @@ 'use strict' -import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js' +import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { CHATROOM_PRIVACY_LEVEL, INVITE_INITIAL_CREATOR, @@ -31,16 +31,14 @@ import { JOINED_GROUP, JOINED_CHATROOM, LEFT_GROUP, - LOGOUT, - OPEN_MODAL, - REPLACE_MODAL + LOGOUT } from '@utils/events.js' import { imageUpload } from '@utils/image.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' -import { CONTRACT_HAS_RECEIVED_KEYS, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import { CHELONIA_RESET, CONTRACT_HAS_RECEIVED_KEYS, 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' @@ -56,6 +54,13 @@ sbp('okTurtles.events/on', LEFT_GROUP, ({ identityContractID, groupContractID }) sbp('gi.notifications/remove', notificationHashes) }) +const JOINED_FAILED_KEY = 'gi.actions/group/join/failed' +let reattemptTimeoutId + +sbp('okTurtles.events/on', CHELONIA_RESET, () => { + sbp('okTurtles.data/delete', JOINED_FAILED_KEY) +}) + export default (sbp('sbp/selectors/register', { 'gi.actions/group/create': async function ({ data: { @@ -451,46 +456,50 @@ export default (sbp('sbp/selectors/register', { const userCSKdata = rootState[userID]._vm.authorizedKeys[userCSKid].data try { - // Share our PEK with the group so that group members can see - // our name and profile information - PEKid && await sbp('gi.actions/out/shareVolatileKeys', { - contractID: params.contractID, - contractName: 'gi.contracts/group', - subjectContractID: userID, - keyIds: [PEKid] - }) - const existingForeignKeys = await sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) - // Check to avoid adding existing keys to the contract - if (!existingForeignKeys?.includes(userCSKid)) { - await sbp('chelonia/out/keyAdd', { - contractID: params.contractID, - contractName: 'gi.contracts/group', - data: [encryptedOutgoingData(params.contractID, CEKid, { - foreignKey: `shelter:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, - id: userCSKid, - data: userCSKdata, - permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'], - allowedActions: '*', - purpose: ['sig'], - ringLevel: Number.MAX_SAFE_INTEGER, - name: `${userID}/${userCSKid}` - })], - signingKeyId: CSKid - }) - } - // Send inviteAccept action to the group to add ourselves to the members list - await sbp('chelonia/contract/wait', params.contractID) - await sbp('gi.actions/group/inviteAccept', { + await sbp('chelonia/out/atomic', { ...omit(params, ['options', 'action', 'hooks', 'encryptionKeyId', 'signingKeyId']), - data: { - // The 'reference' value is used to help keep group joins - // updated. A matching value is required when leaving a group, - // which prevents us from accidentally leaving a group due to - // a previous leave action when re-joining - reference: rootState[userID].groups[params.contractID].hash - }, + data: [ + // Share our PEK with the group so that group members can see + // our name and profile information + PEKid && await sbp('gi.actions/out/shareVolatileKeys', { + contractID: params.contractID, + contractName: 'gi.contracts/group', + subjectContractID: userID, + keyIds: [PEKid], + returnInvocation: true + }), + // Check to avoid adding existing keys to the contract + !existingForeignKeys?.includes(userCSKid) && ['chelonia/out/keyAdd', { + contractID: params.contractID, + contractName: 'gi.contracts/group', + data: [encryptedOutgoingData(params.contractID, CEKid, { + foreignKey: `shelter:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, + id: userCSKid, + data: userCSKdata, + permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'], + allowedActions: '*', + purpose: ['sig'], + ringLevel: Number.MAX_SAFE_INTEGER, + name: `${userID}/${userCSKid}` + })], + signingKeyId: CSKid + }], + // Send inviteAccept action to the group to add ourselves to the members list + await sbp('gi.actions/group/inviteAccept', { + ...omit(params, ['options', 'action', 'hooks', 'encryptionKeyId', 'signingKeyId']), + data: { + // The 'reference' value is used to help keep group joins + // updated. A matching value is required when leaving a group, + // which prevents us from accidentally leaving a group due to + // a previous leave action when re-joining + reference: rootState[userID].groups[params.contractID].hash + }, + returnInvocation: true + }) + ].filter(Boolean), + signingKeyId: CSKid, hooks: { prepublish: params.hooks?.prepublish, postpublish: null @@ -521,11 +530,7 @@ export default (sbp('sbp/selectors/register', { } } catch (e) { console.error('gi.actions/group/join failed!', e) - sbp('gi.ui/prompt', { - heading: L('Failed to join the group'), - question: L('Error details:{br_}{err}', { err: e.message, ...LTags() }), - primaryButton: L('Close') - }) + throw e } finally { // If we called join but it didn't result in any actions being sent, we // may have left the group. In this case, we execute any pending /remove @@ -533,6 +538,34 @@ export default (sbp('sbp/selectors/register', { // the group contract hasn't been called. await sbp('chelonia/contract/release', retainedContracts, { ephemeral: true }) } + }).then(() => { + const map: Map = sbp('okTurtles.data/get', JOINED_FAILED_KEY) + map?.delete(params.contractID) + }).catch(e => { + let map: Map = sbp('okTurtles.data/get', JOINED_FAILED_KEY) + if (!map) { + map = new Map() + sbp('okTurtles.data/set', JOINED_FAILED_KEY, map) + } + + map.set(params.contractID, params) + + const scheduleReattempt = () => { + if (reattemptTimeoutId === undefined) { + reattemptTimeoutId = setTimeout(() => { + reattemptTimeoutId = undefined + if (map.size === 0) return + + sbp('gi.actions/group/reattemptFailedJoins').catch(e => { + console.error('Error running reattemptFailedJoins', e) + scheduleReattempt() + }) + }, 60e3) + } + } + scheduleReattempt() + + throw e }) }, 'gi.actions/group/joinWithInviteSecret': async function (groupId: string, secret: string) { @@ -571,6 +604,28 @@ export default (sbp('sbp/selectors/register', { await sbp('chelonia/contract/release', groupId, { ephemeral: true }) } }, + 'gi.actions/group/reattemptFailedJoins': async function () { + const ourGroups = sbp('state/vuex/getters').ourGroups + const map: Map = sbp('okTurtles.data/get', JOINED_FAILED_KEY) + if (!map) return + + return await Promise.allSettled([...map.entries()].map(([contractID, params]) => { + if (!ourGroups.includes(contractID)) { + map.delete(contractID) + return undefined + } + + return sbp('gi.actions/group/join', params).catch(e => { + console.error('Error on group join re-attempt', params, e) + + throw e + }) + })).then((results) => { + if (results.some(result => result.status === 'rejected')) { + throw new Error('Error on group join re-attempt') + } + }) + }, 'gi.actions/group/shareNewKeys': (contractID: string, newKeys) => { const rootState = sbp('chelonia/rootState') const state = rootState[contractID] @@ -911,37 +966,6 @@ export default (sbp('sbp/selectors/register', { }) } }), - 'gi.actions/group/displayMincomeChangedPrompt': async function ({ data }: GIActionParams) { - const { withGroupCurrency } = sbp('state/vuex/getters') - const promptOptions = data.increased - ? { - heading: L('Mincome changed'), - question: L('Do you make at least {amount} per month?', { amount: withGroupCurrency(data.amount) }), - primaryButton: data.memberType === 'pledging' ? L('No') : L('Yes'), - secondaryButton: data.memberType === 'pledging' ? L('Yes') : L('No') - } - : { - heading: L('Automatically switched to pledging {zero}', { zero: withGroupCurrency(0) }), - question: L('You now make more than the mincome. Would you like to increase your pledge?'), - primaryButton: L('Yes'), - secondaryButton: L('No') - } - - const primaryButtonSelected = await sbp('gi.ui/prompt', promptOptions) - if (primaryButtonSelected) { - // NOTE: emtting 'REPLACE_MODAL' instead of 'OPEN_MODAL' here because 'Prompt' modal is open at this point (by 'gi.ui/prompt' action above). - 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 () => { diff --git a/frontend/controller/actions/index.js b/frontend/controller/actions/index.js index 5a13343e04..85380041a9 100644 --- a/frontend/controller/actions/index.js +++ b/frontend/controller/actions/index.js @@ -25,7 +25,8 @@ sbp('sbp/selectors/register', { contractID, contractName, subjectContractID, - keyIds + keyIds, + returnInvocation }) => { if (contractID === subjectContractID) { return @@ -74,12 +75,15 @@ sbp('sbp/selectors/register', { })) } - await sbp('chelonia/out/keyShare', { + const invocation = ['chelonia/out/keyShare', { contractID, contractName, data: encryptedOutgoingData(contractID, CEKid, payload), signingKeyId - }) + }] + if (returnInvocation) return invocation + + await sbp(...invocation) } finally { await sbp('chelonia/contract/release', contractID, { ephemeral: true }) } diff --git a/frontend/controller/app/group.js b/frontend/controller/app/group.js index e2c26898b5..c37ccc5cde 100644 --- a/frontend/controller/app/group.js +++ b/frontend/controller/app/group.js @@ -113,5 +113,48 @@ export default (sbp('sbp/selectors/register', { } else { sbp('okTurtles.events/emit', OPEN_MODAL, 'AddMembers') } + }, + 'gi.app/group/checkAndSeeProposal': function ({ contractID, data }: GIActionParams) { + const rootGetters = sbp('state/vuex/getters') + const rootState = sbp('state/vuex/state') + if (rootState.currentGroupId !== contractID) { + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID }) + } + + const openProposalIds = Object.keys(rootGetters.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.app/group/displayMincomeChangedPrompt': async function ({ contractID, data }: GIActionParams) { + const rootGetters = sbp('state/vuex/getters') + const rootState = sbp('state/vuex/state') + if (rootState.currentGroupId !== contractID) { + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID }) + } + + const { withGroupCurrency } = rootGetters + const promptOptions = data.increased + ? { + heading: L('Mincome changed'), + question: L('Do you make at least {amount} per month?', { amount: withGroupCurrency(data.amount) }), + primaryButton: data.memberType === 'pledging' ? L('No') : L('Yes'), + secondaryButton: data.memberType === 'pledging' ? L('Yes') : L('No') + } + : { + heading: L('Automatically switched to pledging {zero}', { zero: withGroupCurrency(0) }), + question: L('You now make more than the mincome. Would you like to increase your pledge?'), + primaryButton: L('Yes'), + secondaryButton: L('No') + } + + const primaryButtonSelected = await sbp('gi.ui/prompt', promptOptions) + if (primaryButtonSelected) { + // NOTE: emtting 'REPLACE_MODAL' instead of 'OPEN_MODAL' here because 'Prompt' modal is open at this point (by 'gi.ui/prompt' action above). + sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails') + } } }): string[]) diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index b3553b4de2..afef5b8d65 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -79,6 +79,26 @@ sbp('sbp/selectors/register', { }) }) + if (typeof PeriodicSyncManager === 'function') { + navigator.permissions.query({ + name: 'periodic-background-sync' + }).then((status) => { + if (status.state !== 'granted') { + console.error('[service-workers/setup] Periodic sync event permission denied') + return + } + + return navigator.serviceWorker.ready.then((registration) => + registration.periodicSync.register('periodic-notifications', { + // An interval of 12 hours + minInterval: 12 * HOURS_MILLIS + }) + ) + }).catch((e) => { + console.error('[service-workers/setup] Error setting up periodic background sync events', e) + }) + } + // Keep the service worker alive while the window is open // The default idle timeout on Chrome and Firefox is 30 seconds. We send // a ping message every 5 seconds to ensure that the worker remains @@ -104,9 +124,19 @@ sbp('sbp/selectors/register', { break } case 'navigate': { + if (data.groupID) { + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID: data.groupID }) + } sbp('controller/router').push({ path: data.path }).catch(console.warn) break } + // `sbp` invocations from the SW to the app. Used by the + // `notificationclick` handler for notifications that have an + // `sbpInvocation` instead of a `linkTo` property. + case 'sbp': { + sbp(...deserializer(event.data.data)) + break + } case CAPTURED_LOGS: { // Emit silently to avoid flooding logs with event emitted entries silentEmit(CAPTURED_LOGS, ...deserializer(event.data.data)) diff --git a/frontend/controller/serviceworkers/push.js b/frontend/controller/serviceworkers/push.js index d6eea834a2..af5472e96f 100644 --- a/frontend/controller/serviceworkers/push.js +++ b/frontend/controller/serviceworkers/push.js @@ -1,3 +1,4 @@ +import { L } from '@common/common.js' import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' import { makeNotification } from '@model/notifications/nativeNotification.js' import sbp from '@sbp/sbp' @@ -135,10 +136,15 @@ self.addEventListener('push', function (event) { }).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 }) + return makeNotification({ title: L('Chatroom activity'), body: L('New chatroom message. An iOS bug prevents us from saying what it is.') }) + } else if (data.contractType === 'gi.contracts/group') { + return makeNotification({ title: L('Group activity'), body: L('New group activity. An iOS bug prevents us from saying what it is.') }) } })) + } else if (data.type === 'recurring') { + event.waitUntil( + sbp('gi.periodicNotifications/init') + ) } }, false) @@ -156,3 +162,11 @@ self.addEventListener('pushsubscriptionchange', function (event) { } })()) }, false) + +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'periodic-notifications') { + event.waitUntil( + sbp('gi.periodicNotifications/init') + ) + } +}) diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index e674a5046b..da37cc9977 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -1,15 +1,18 @@ 'use strict' import { MESSAGE_RECEIVE, MESSAGE_SEND, PROPOSAL_ARCHIVED } from '@model/contracts/shared/constants.js' +import periodicNotificationEntries from '@model/notifications/mainPeriodicNotificationEntries.js' +import { makeNotification } from '@model/notifications/nativeNotification.js' +import '@model/notifications/periodicNotifications.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 notificationGetters from '~/frontend/model/notifications/getters.js' import '~/frontend/model/notifications/selectors.js' import setupChelonia from '~/frontend/setupChelonia.js' import { KV_KEYS } from '~/frontend/utils/constants.js' @@ -27,6 +30,7 @@ import { NOTIFICATION_STATUS_LOADED, OFFLINE, ONLINE, SERIOUS_ERROR, SWITCH_GROUP } from '../../utils/events.js' import './push.js' +import './sw-namespace.js' deserializer.register(GIMessage) deserializer.register(Secret) @@ -77,6 +81,13 @@ const setupRootState = () => { 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) + + if (!rootState.periodicNotificationAlreadyFiredMap) { + rootState.periodicNotificationAlreadyFiredMap = { + alreadyFired: Object.create(null), // { notificationKey: boolean }, + lastRun: Object.create(null) // { notificationKey: number }, + } + } } sbp('okTurtles.events/on', CHELONIA_RESET, setupRootState) @@ -148,40 +159,70 @@ sbp('sbp/selectors/register', { 'state/vuex/state': () => sbp('chelonia/rootState'), 'state/vuex/getters': (() => { // Singleton lazily generated getters - let obj + let computedGetters + return () => { - if (!obj) { - obj = Object.create(null) - Object.defineProperties(obj, Object.fromEntries(Object.entries(getters).map(([getter, fn]: [string, Function]) => { + if (!computedGetters) { + computedGetters = Object.create(null) + Object.defineProperties(computedGetters, Object.fromEntries(Object.entries(getters).map(([getter, fn]: [string, Function]) => { return [getter, { - get: () => { + get: function () { const state = sbp('chelonia/rootState') - return fn(state, obj) + // `fn` takes the state as the first parameter and the getters as + // a second parameter. We use `this` instead of `computedGetters` + // so that we can locally override the `computedGetters` object + // (e.g., using inheritance or a `Proxy`) to redefine certain + // getters. This is convenient, but it's incompatible with the + // way Vuex getters work, which do _not_ use `this`. + // This incompatibility is fine, since one has to go out of their + // way to make `this` and `computedGetters` different. + return fn(state, this) + } + }] + }))) + Object.defineProperties(computedGetters, Object.fromEntries(Object.entries(chatroomGetters).map(([getter, fn]: [string, Function]) => { + return [getter, { + get: function () { + const state = sbp('chelonia/rootState') + // `state.chatroom` represents the `chatroom` module. For the SW, + // this is defined in `sw-primary.js`. + // The same idea applies here for the use of `this` instead of + // `computedGetters` as above. + return fn(state.chatroom || {}, this, state) } }] }))) - Object.defineProperties(obj, Object.fromEntries(Object.entries(chatroomGetters).map(([getter, fn]: [string, Function]) => { + Object.defineProperties(computedGetters, Object.fromEntries(Object.entries(notificationGetters).map(([getter, fn]: [string, Function]) => { return [getter, { - get: () => { + get: function () { 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) + // The same idea applies here for the use of `this` instead of + // `computedGetters` as above. + return fn(state.notifications || {}, this, state) } }] }))) + Object.defineProperty(computedGetters, 'currentPaymentPeriodForGroup', { + get: function () { + return (state, getters) => { + return (state) => getters.periodStampGivenDateForGroup(state, new Date()) + } + } + }) } - return obj + return computedGetters } })() }) -const x = new URL(self.location) +const ourLocation = new URL(self.location) sbp('sbp/selectors/register', { 'controller/router': () => { - return { options: { base: x.searchParams.get('routerBase') } } + return { options: { base: ourLocation.searchParams.get('routerBase') } } } }) @@ -189,6 +230,16 @@ sbp('sbp/selectors/register', { 'appLogs/save': () => sbp('swLogs/save') }) +sbp('gi.periodicNotifications/importNotifications', periodicNotificationEntries) + +// Set up periodic notifications on the `CHELONIA_RESET` event. We do this here, +// before calling `setupRootState`, so that the `CHELONIA_RESET` it will trigger +// will set up periodic notifications. +sbp('okTurtles.events/on', CHELONIA_RESET, () => { + sbp('gi.periodicNotifications/clearStatesAndStopTimers') + sbp('gi.periodicNotifications/init') +}) + sbp('okTurtles.data/set', 'API_URL', self.location.origin) setupRootState() const setupPromise = setupChelonia() @@ -315,13 +366,38 @@ self.addEventListener('notificationclick', event => { return 0 }) + // If there are no open windows, open a new window when the notification + // is clicked if (!clientList.length) { - return self.clients.openWindow(`${sbp('controller/router').options.base}${event.notification.data.path ?? '/'}`) + return self.clients.openWindow(`${sbp('controller/router').options.base}${event.notification.data.path ?? '/'}`).then((client) => { + if (event.notification.data?.sbpInvocation) { + const { data } = serializer(event.notification.data.sbpInvocation) + client.postMessage({ + type: 'sbp', + data + }) + } else if (event.notification.data?.groupID) { + client.postMessage({ + type: 'sbp', + data: ['state/vuex/commit', 'setCurrentGroupId', { contractID: event.notification.data.groupID }] + }) + } + + return client + }) } + // Otherwise, pick the first client const client = clientList[0] - if (event.notification.data?.path) { + if (event.notification.data?.sbpInvocation) { + const { data } = serializer(event.notification.data.sbpInvocation) + client.postMessage({ + type: 'sbp', + data + }) + } else if (event.notification.data?.path) { client.postMessage({ type: 'navigate', + groupID: event.notification.data.groupID, path: event.notification.data.path }) } @@ -367,3 +443,23 @@ sbp('okTurtles.events/on', NEW_CHATROOM_UNREAD_POSITION, ({ chatRoomID, messageH } sbp('okTurtles.events/emit', CHELONIA_STATE_MODIFIED) }) + +sbp('okTurtles.events/on', NOTIFICATION_EMITTED, (notification) => { + const rootGetters = sbp('state/vuex/getters') + const icon = notification.avatarUserID && rootGetters.ourContactProfilesById[notification.avatarUserID]?.picture + ? rootGetters.ourContactProfilesById[notification.avatarUserID].picture + : notification.groupID + ? rootGetters.groupSettingsForGroup(notification.groupID).groupPicture + : undefined + + makeNotification({ + icon: icon || undefined, + title: notification.title, + body: notification.plaintextBody, + groupID: notification.groupID, + path: notification.linkTo, + sbpInvocation: notification.sbpInvocation + }).catch(e => { + console.error('Error displaying native notification', e) + }) +}) diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index dd9b525078..cd8d8dfbcf 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -1582,15 +1582,6 @@ sbp('chelonia/defineContract', { pledgeAmount: 0 } }) - - await sbp('gi.actions/group/displayMincomeChangedPrompt', { - contractID, - data: { - amount: toAmount, - memberType, - increased: mincomeIncreased - } - }) } sbp('gi.notifications/emit', 'MINCOME_CHANGED', { diff --git a/frontend/model/contracts/shared/getters/group.js b/frontend/model/contracts/shared/getters/group.js index bf307e40dc..f01a513a18 100644 --- a/frontend/model/contracts/shared/getters/group.js +++ b/frontend/model/contracts/shared/getters/group.js @@ -10,12 +10,66 @@ import { createPaymentInfo, paymentHashesFromPaymentPeriod } from '../functions. import { PAYMENT_COMPLETED } from '../payments/index.js' import { addTimeToDate, dateFromPeriodStamp, dateIsWithinPeriod, dateToPeriodStamp, periodStampsForDate } from '../time.js' +/* +`-ForGroup` pattern: + +Some getters (see, e.g., `groupSettings` and `groupSettingsForGroup`) are +implemented in pairs, with a variant having the `ForGroup` suffix. This is +because the non-suffixed version is supposed to work for the current group +(meaning the result of `currentGroupState`), while the suffixed version takes +a group state as a parameter. To avoid redundancy, the non-suffixed version is +implemented using the suffixed version (i.e., calling it with +`getters.currentGroupState` as a parameter). + +Why state and not contract ID? + +We don't use an `Id` version, e.g., `-ForGroupId`, because the ID isn't +necessarily known. Contracts rely on a different patter, a getter to get the +current state (e.g., `currentGroupState`) and using a `ForGroupId` pattern would +break this and require significant refactoring. By using the state, getters are +easily interoperable in all execution environments we support (i.e., browser +windows, SW and contracts) + +Why do we need this? + +The primary motivation for this pattern is to reduce redundancy and improve the +flexibility of getters, as well as being to re-use complex logic in getters +from most places. Currently, we use getters from three locations: in the 'app' +(e.g., a browser window), in a service worker and in contracts. + +The 'app' has a current group, so the non-prefixed version +(e.g., `groupSettings`) is generally sufficient. Contracts have an implicit +current group, which is the current contract. In these cases, getters work by +injecting a `currentGroupState` getter that resolves to the current contract +state. However, the service worker doesn't have an implicit or explicit current +group, and 'injecting' a `currentGroupState` can't be done in a way that is +understandable and also is compatible with how Vuex getters work (*), should we +decide to move some of the SW code into the app. + +Since we want the SW to be able to do things for all groups without having a +global 'current group', and we also don't want to be essentially re-defining +getters when they're needed, the prefixed version (e.g., `groupSettingsForGroup`) +can be used to bridge the gap and use existing definitions. This approach is +potentially also useful in the 'app', if we want to access information about a +group which isn't the current one (an example of this could be if we wanted to +say "User 'alice' and you also have group 'foo' in common"). + +(*) One potential alternative solution is using a prototype-inheritance or a +Proxy object on the getters object to override `currentGroupState`. This can be +made to work in the SW using `this` magic, but is incompatible with the way +Vuex works. Also, this approach is potentially confusing and hard to read. + +*/ + export default ({ currentGroupOwnerID (state, getters) { return getters.currentGroupState.groupOwnerID }, + groupSettingsForGroup (state, getters) { + return (state) => state.settings || {} + }, groupSettings (state, getters) { - return getters.currentGroupState.settings || {} + return getters.groupSettingsForGroup(getters.currentGroupState) }, profileActive (state, getters) { return member => { @@ -29,52 +83,70 @@ export default ({ return profiles?.[member]?.status === PROFILE_STATUS.PENDING } }, - groupProfile (state, getters) { - return member => { - const profiles = getters.currentGroupState.profiles + groupProfileForGroup (state, getters) { + return (state, member) => { + const profiles = state.profiles return profiles && profiles[member] && { ...profiles[member], get lastLoggedIn () { + // Yes, technically `currentGroupLastLoggedIn` is for the current + // group, but we don't necessarily know the correct group ID here. return getters.currentGroupLastLoggedIn[member] || this.joinedDate } } } }, - groupProfiles (state, getters) { - const profiles = {} - for (const member in (getters.currentGroupState.profiles || {})) { - const profile = getters.groupProfile(member) - if (profile.status === PROFILE_STATUS.ACTIVE) { - profiles[member] = profile + groupProfile (state, getters) { + return member => getters.groupProfileForGroup(getters.currentGroupState, member) + }, + groupProfilesForGroup (state, getters) { + return (state) => { + const profiles = {} + for (const member in (state.profiles || {})) { + const profile = getters.groupProfileForGroup(state, member) + if (profile.status === PROFILE_STATUS.ACTIVE) { + profiles[member] = profile + } } + return profiles } - return profiles + }, + groupProfiles (state, getters) { + return getters.groupProfilesForGroup(getters.currentGroupState) }, groupCreatedDate (state, getters) { return getters.groupProfile(getters.currentGroupOwnerID).joinedDate }, + groupMincomeAmountForGroup (state, getters) { + return state => getters.groupSettingsForGroup(state).mincomeAmount + }, groupMincomeAmount (state, getters) { - return getters.groupSettings.mincomeAmount + return getters.groupMincomeAmountForGroup(getters.currentGroupState) }, groupMincomeCurrency (state, getters) { return getters.groupSettings.mincomeCurrency }, // Oldest period key first. - groupSortedPeriodKeys (state, getters) { - const { distributionDate, distributionPeriodLength } = getters.groupSettings - if (!distributionDate) return [] - // The .sort() call might be only necessary in older browser which don't maintain object key ordering. - // A comparator function isn't required for now since our keys are ISO strings. - const keys = Object.keys(getters.groupPeriodPayments).sort() - // Append the waiting period stamp if necessary. - if (!keys.length && MAX_SAVED_PERIODS > 0) { - keys.push(dateToPeriodStamp(addTimeToDate(distributionDate, -distributionPeriodLength))) - } - // Append the distribution date if necessary. - if (keys[keys.length - 1] !== distributionDate) { - keys.push(distributionDate) + groupSortedPeriodKeysForGroup (state, getters) { + return state => { + const { distributionDate, distributionPeriodLength } = getters.groupSettingsForGroup(state) + if (!distributionDate) return [] + // The .sort() call might be only necessary in older browser which don't maintain object key ordering. + // A comparator function isn't required for now since our keys are ISO strings. + const keys = Object.keys(getters.groupPeriodPaymentsForGroup(state)).sort() + // Append the waiting period stamp if necessary. + if (!keys.length && MAX_SAVED_PERIODS > 0) { + keys.push(dateToPeriodStamp(addTimeToDate(distributionDate, -distributionPeriodLength))) + } + // Append the distribution date if necessary. + if (keys[keys.length - 1] !== distributionDate) { + keys.push(distributionDate) + } + return keys } - return keys + }, + groupSortedPeriodKeys (state, getters) { + return getters.groupSortedPeriodKeysForGroup(getters.currentGroupState) }, // paymentTotalfromMembertoMemberID (state, getters) { // // this code was removed in https://github.com/okTurtles/group-income/pull/1691 @@ -84,46 +156,67 @@ export default ({ // The following three getters return either a known period stamp for the given date, // or a predicted one according to the period length. // They may also return 'undefined', in which case the caller should check archived data. - periodStampGivenDate (state, getters) { - return (date: string | Date, periods?: string[]): string | void => { + periodStampGivenDateForGroup (state, getters) { + return (state, date: string | Date, periods?: string[]): string | void => { return periodStampsForDate(date, { - knownSortedStamps: periods || getters.groupSortedPeriodKeys, - periodLength: getters.groupSettings.distributionPeriodLength + knownSortedStamps: periods || getters.groupSortedPeriodKeysForGroup(state), + periodLength: getters.groupSettingsForGroup(state).distributionPeriodLength }).current } }, - periodBeforePeriod (state, getters) { - return (periodStamp: string, periods?: string[]): string | void => { + periodStampGivenDate (state, getters) { + return (date: string | Date, periods?: string[]): string | void => { + return getters.periodStampGivenDateForGroup(getters.currentGroupState, date, periods) + } + }, + periodBeforePeriodForGroup (state, getters) { + return (groupState: Object, periodStamp: string, periods?: string[]): string | void => { return periodStampsForDate(periodStamp, { - knownSortedStamps: periods || getters.groupSortedPeriodKeys, - periodLength: getters.groupSettings.distributionPeriodLength + knownSortedStamps: periods || getters.groupSortedPeriodKeysForGroup(groupState), + periodLength: getters.groupSettingsForGroup(groupState).distributionPeriodLength }).previous } }, - periodAfterPeriod (state, getters) { - return (periodStamp: string, periods?: string[]): string | void => { + periodBeforePeriod (state, getters) { + return (periodStamp: string, periods?: string[]) => getters.periodBeforePeriodForGroup(getters.currentGroupState, periodStamp, periods) + }, + periodAfterPeriodForGroup (state, getters) { + return (groupState: Object, periodStamp: string, periods?: string[]): string | void => { return periodStampsForDate(periodStamp, { - knownSortedStamps: periods || getters.groupSortedPeriodKeys, - periodLength: getters.groupSettings.distributionPeriodLength + knownSortedStamps: periods || getters.groupSortedPeriodKeysForGroup(groupState), + periodLength: getters.groupSettingsForGroup(groupState).distributionPeriodLength }).next } }, - dueDateForPeriod (state, getters) { - return (periodStamp: string, periods?: string[]) => { + periodAfterPeriod (state, getters) { + return (periodStamp: string, periods?: string[]) => getters.periodAfterPeriodForGroup(getters.currentGroupState, periodStamp, periods) + }, + dueDateForPeriodForGroup (state, getters) { + return (state, periodStamp: string, periods?: string[]) => { // NOTE: logically it's should be 1 milisecond before the periodAfterPeriod // 1 mili-second doesn't make any difference to the users // so periodAfterPeriod is used to make it simple - return getters.periodAfterPeriod(periodStamp, periods) + return getters.periodAfterPeriodForGroup(state, periodStamp, periods) } }, - paymentHashesForPeriod (state, getters) { - return (periodStamp) => { - const periodPayments = getters.groupPeriodPayments[periodStamp] + dueDateForPeriod (state, getters) { + return (periodStamp: string, periods?: string[]) => { + return getters.dueDateForPeriodForGroup(getters.currentGroupState, periodStamp, periods) + } + }, + paymentHashesForPeriodForGroup (state, getters) { + return (state, periodStamp) => { + const periodPayments = getters.groupPeriodPaymentsForGroup(state)[periodStamp] if (periodPayments) { return paymentHashesFromPaymentPeriod(periodPayments) } } }, + paymentHashesForPeriod (state, getters) { + return (periodStamp) => { + return getters.paymentHashesForPeriodForGroup(getters.currentGroupState, periodStamp) + } + }, groupMembersByContractID (state, getters) { return Object.keys(getters.groupProfiles) }, @@ -169,9 +262,14 @@ export default ({ groupMincomeSymbolWithCode (state, getters) { return getters.groupCurrency?.symbolWithCode }, - groupPeriodPayments (state, getters): Object { + groupPeriodPaymentsForGroup (state, getters): Object { // note: a lot of code expects this to return an object, so keep the || {} below - return getters.currentGroupState.paymentsByPeriod || {} + return (state) => { + return state.paymentsByPeriod || {} + } + }, + groupPeriodPayments (state, getters): Object { + return getters.groupPeriodPaymentsForGroup(getters.currentGroupState) }, groupThankYousFrom (state, getters): Object { return getters.currentGroupState.thankYousFrom || {} @@ -198,23 +296,23 @@ export default ({ // getter is named haveNeedsForThisPeriod instead of haveNeedsForPeriod because it uses // getters.groupProfiles - and that is always based on the most recent values. we still // pass in the current period because it's used to set the "when" property - haveNeedsForThisPeriod (state, getters) { - return (currentPeriod: string) => { + haveNeedsForThisPeriodForGroup (state, getters) { + return (state, currentPeriod: string) => { // NOTE: if we ever switch back to the "real-time" adjusted distribution algorithm, // make sure that this function also handles userExitsGroupEvent - const groupProfiles = getters.groupProfiles // TODO: these should use the haveNeeds for the specific period's distribution period + const groupProfiles = getters.groupProfilesForGroup(state) // TODO: these should use the haveNeeds for the specific period's distribution period const haveNeeds = [] for (const memberID in groupProfiles) { const { incomeDetailsType, joinedDate } = groupProfiles[memberID] if (incomeDetailsType) { const amount = groupProfiles[memberID][incomeDetailsType] - const haveNeed = incomeDetailsType === 'incomeAmount' ? amount - getters.groupMincomeAmount : amount + const haveNeed = incomeDetailsType === 'incomeAmount' ? amount - getters.groupMincomeAmountForGroup(state) : amount // construct 'when' this way in case we ever use a pro-rated algorithm let when = dateFromPeriodStamp(currentPeriod).toISOString() if (dateIsWithinPeriod({ date: joinedDate, periodStart: currentPeriod, - periodLength: getters.groupSettings.distributionPeriodLength + periodLength: getters.groupSettingsForGroup(state).distributionPeriodLength })) { when = joinedDate } @@ -224,12 +322,17 @@ export default ({ return haveNeeds } }, - paymentsForPeriod (state, getters) { - return (periodStamp) => { - const hashes = getters.paymentHashesForPeriod(periodStamp) + haveNeedsForThisPeriod (state, getters) { + return (currentPeriod: string) => { + return getters.haveNeedsForThisPeriodForGroup(getters.currentGroupState, currentPeriod) + } + }, + paymentsForPeriodForGroup (state, getters) { + return (state, periodStamp) => { + const hashes = getters.paymentHashesForPeriodForGroup(state, periodStamp) const events = [] if (hashes && hashes.length > 0) { - const payments = getters.currentGroupState.payments + const payments = state.payments for (const paymentHash of hashes) { const payment = payments[paymentHash] if (payment.data.status === PAYMENT_COMPLETED) { @@ -239,6 +342,11 @@ export default ({ } return events } + }, + paymentsForPeriod (state, getters) { + return (periodStamp) => { + return getters.paymentsForPeriodForGroup(getters.currentGroupState, periodStamp) + } } // distributionEventsForMonth (state, getters) { // return (monthstamp) => { diff --git a/frontend/model/getters.js b/frontend/model/getters.js index 071a975194..981039ffdc 100644 --- a/frontend/model/getters.js +++ b/frontend/model/getters.js @@ -58,6 +58,15 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => // 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) { + // The service worker should not be using this getter. + if (process.env.NODE_ENV === 'development' || process.env.CI) { + // $FlowFixMe[cannot-resolve-name] + if (typeof Window === 'undefined') { + const error = new Error('Tried to access currentGroupState from outside a browsing context') + Promise.reject(error) + throw error + } + } return state[state.currentGroupId] || {} // avoid "undefined" vue errors at inoportune times }, currentIdentityState (state) { @@ -75,8 +84,11 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => ourPendingAccept (state, getters) { return getters.pendingAccept(getters.ourIdentityContractId) }, + ourGroupProfileForGroup (state, getters) { + return (state) => getters.groupProfileForGroup(state, getters.ourIdentityContractId) + }, ourGroupProfile (state, getters) { - return getters.groupProfile(getters.ourIdentityContractId) + return getters.ourGroupProfileForGroup(getters.currentGroupState) }, ourUserDisplayName (state, getters) { // TODO - refactor Profile and Welcome and any other component that needs this @@ -86,6 +98,14 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => ourIdentityContractId (state) { return state.loggedIn && state.loggedIn.identityContractID }, + ourGroups (state, getters) { + const identityContractID = getters.ourIdentityContractId + if (!identityContractID) return [] + + return Object.keys(state[identityContractID]?.groups || {}).filter( + (gId) => !state[identityContractID].groups[gId].hasLeft && state[gId] + ) + }, currentGroupLastLoggedIn (state) { return state.lastLoggedIn[state.currentGroupId] || {} }, @@ -172,8 +192,13 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => return profile?.displayName || profile?.username || state.reverseNamespaceLookups[userID] || userID } }, + thisPeriodPaymentInfoForGroup (state, getters) { + return (state) => { + return getters.groupPeriodPaymentsForGroup(state)[getters.currentPaymentPeriodForGroup(state)] + } + }, thisPeriodPaymentInfo (state, getters) { - return getters.groupPeriodPayments[getters.currentPaymentPeriod] + return getters.thisPeriodPaymentInfoForGroup(getters.currentGroupState) }, latePayments (state, getters) { const periodPayments = getters.groupPeriodPayments @@ -193,32 +218,37 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => }) }, // 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) - }) + groupIncomeAdjustedDistributionForGroup (state, getters) { + return (state) => { + const paymentInfo = getters.thisPeriodPaymentInfoForGroup(state) + if (paymentInfo && paymentInfo.lastAdjustedDistribution) { + return paymentInfo.lastAdjustedDistribution + } else { + const period = getters.currentPaymentPeriodForGroup(state) + return adjustedDistribution({ + distribution: unadjustedDistribution({ + haveNeeds: getters.haveNeedsForThisPeriodForGroup(state, period), + minimize: getters.groupSettingsForGroup(state).minimizeDistribution + }), + payments: getters.paymentsForPeriodForGroup(state, period), + dueOn: getters.dueDateForPeriodForGroup(state, period) + }) + } } }, - ourPaymentsSentInPeriod (state, getters) { - return (period) => { - const periodPayments = getters.groupPeriodPayments + groupIncomeAdjustedDistribution (state, getters) { + return getters.groupIncomeAdjustedDistributionForGroup(getters.currentGroupState) + }, + ourPaymentsSentInPeriodForGroup (state, getters) { + return (state, period) => { + const periodPayments = getters.groupPeriodPaymentsForGroup(state) 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 + const allPayments = state.payments for (const toMemberID in paymentsFrom[ourIdentityContractId]) { for (const paymentHash of paymentsFrom[ourIdentityContractId][toMemberID]) { const { data, meta, height } = allPayments[paymentHash] @@ -230,16 +260,19 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) } }, - ourPaymentsReceivedInPeriod (state, getters) { - return (period) => { - const periodPayments = getters.groupPeriodPayments + ourPaymentsSentInPeriod (state, getters) { + return (period) => getters.ourPaymentsSentInPeriodForGroup(getters.currentGroupState, period) + }, + ourPaymentsReceivedInPeriodForGroup (state, getters) { + return (state, period) => { + const periodPayments = getters.groupPeriodPaymentsForGroup(state) 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 + const allPayments = state.payments for (const fromMemberID in paymentsFrom) { for (const toMemberID in paymentsFrom[fromMemberID]) { if (toMemberID === ourIdentityContractId) { @@ -255,28 +288,36 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) => 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) + ourPaymentsReceivedInPeriod (state, getters) { + return (period) => getters.ourPaymentsReceivedInPeriodForGroup(getters.currentGroupState, period) + }, + ourPaymentsForGroup (state, getters) { + return (state) => { + const periodPayments = getters.groupPeriodPaymentsForGroup(state) + if (Object.keys(periodPayments).length === 0) return + const ourIdentityContractId = getters.ourIdentityContractId + const cPeriod = getters.currentPaymentPeriodForGroup(state) + const pPeriod = getters.periodBeforePeriodForGroup(state, cPeriod) + const currentSent = getters.ourPaymentsSentInPeriodForGroup(state, cPeriod) + const previousSent = getters.ourPaymentsSentInPeriodForGroup(state, pPeriod) + const currentReceived = getters.ourPaymentsReceivedInPeriodForGroup(state, cPeriod) + const previousReceived = getters.ourPaymentsReceivedInPeriodForGroup(state, 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) - } + // TODO: take into account pending payments that have been sent but not yet completed + const todo = () => { + return getters.groupIncomeAdjustedDistributionForGroup(state).filter(p => p.fromMemberID === ourIdentityContractId) + } - return { - sent: [...currentSent, ...previousSent], - received: [...currentReceived, ...previousReceived], - todo: todo() + return { + sent: [...currentSent, ...previousSent], + received: [...currentReceived, ...previousReceived], + todo: todo() + } } }, + ourPayments (state, getters) { + return getters.ourPaymentsForGroup(getters.currentGroupState) + }, ourPaymentsSummary (state, getters) { const isNeeder = getters.ourGroupProfile.incomeDetailsType === 'incomeAmount' const ourIdentityContractId = getters.ourIdentityContractId diff --git a/frontend/model/notifications/getters.js b/frontend/model/notifications/getters.js new file mode 100644 index 0000000000..daa41a8eb9 --- /dev/null +++ b/frontend/model/notifications/getters.js @@ -0,0 +1,81 @@ +import { MAX_AGE_READ, MAX_AGE_UNREAD } from './storageConstants.js' +import { age, isNew, isOlder } from './utils.js' + +const getters: { [x: string]: (state: Object, getters: { [x: string]: any }, rootState: Object) => any } = { + notifications (state, getters, rootState) { + return state.items.map(item => { + const notification = { ...item, ...state.status[item.hash] } + // Notifications older than MAX_AGE_UNREAD are discarded + if (age(notification) > MAX_AGE_UNREAD) { + return null + } else if (!notification.read && age(notification) > MAX_AGE_READ) { + // Unread notifications older than MAX_AGE_READ are automatically + // marked as read + notification.read = true + } + return notification + }).filter(Boolean) + }, + // Notifications relevant to the current group only. + currentGroupNotifications (state, getters, rootState) { + return getters.notifications.filter(item => item.groupID === rootState.currentGroupId) + }, + + // Notifications relevant to a specific group. + notificationsByGroup (state, getters) { + return groupID => getters.notifications.filter(item => item.groupID === groupID) + }, + + currentGroupUnreadNotificationCount (state, getters) { + return getters.currentGroupUnreadNotifications.length + }, + + // Unread notifications relevant to the current group only. + currentGroupUnreadNotifications (state, getters, rootState) { + return getters.currentGroupNotifications.filter(item => !item.read) + }, + + currentNewNotifications (state, getters) { + return getters.currentNotifications.filter(isNew) + }, + + currentNotificationCount (state, getters) { + return getters.currentNotifications.length + }, + + // Notifications relevant to the current group, plus notifications that don't belong to any group in particular. + currentNotifications (state, getters, rootState) { + return getters.notifications.filter(item => !item.groupID || item.groupID === rootState.currentGroupId) + }, + + currentOlderNotifications (state, getters) { + return getters.currentNotifications.filter(isOlder) + }, + + currentUnreadNotificationCount (state, getters) { + return getters.currentNotifications.filter(item => !item.read).length + }, + + currentUnreadNotifications (state, getters) { + return getters.currentNotifications.filter(item => !item.read) + }, + + totalUnreadNotificationCount (state, getters) { + return getters.notifications.filter(item => !item.read).length + }, + + // Finds what number to display on a group's avatar badge in the sidebar. Used in GroupsList.vue. + unreadGroupNotificationCountFor (state, getters) { + return (groupID) => getters.unreadGroupNotificationsFor(groupID).length + }, + + unreadGroupNotificationsFor (state, getters, rootState) { + return (groupID) => ( + groupID === rootState.currentGroupId + ? getters.currentGroupUnreadNotifications + : getters.notifications.filter(item => !item.read && item.groupID === groupID) + ) + } +} + +export default getters diff --git a/frontend/model/notifications/mainNotificationsMixin.js b/frontend/model/notifications/mainNotificationsMixin.js index 77415ca087..e9d1d0409c 100644 --- a/frontend/model/notifications/mainNotificationsMixin.js +++ b/frontend/model/notifications/mainNotificationsMixin.js @@ -1,10 +1,8 @@ 'use strict' +import { compareISOTimestamps, dateToPeriodStamp, MONTHS_MILLIS } from '@model/contracts/shared/time.js' import sbp from '@sbp/sbp' import { PERIODIC_NOTIFICATION_TYPE } from './periodicNotifications.js' -import { compareISOTimestamps, comparePeriodStamps, dateToPeriodStamp, DAYS_MILLIS, MONTHS_MILLIS } from '@model/contracts/shared/time.js' -import { STATUS_OPEN, PROPOSAL_GENERIC } from '@model/contracts/shared/constants.js' -import { extractProposalData } from './utils.js' // util functions const myNotificationHas = (checkFunc, groupId = '') => { @@ -69,160 +67,6 @@ const oneTimeNotificationEntries = [ ] const periodicNotificationEntries = [ - { - type: PERIODIC_NOTIFICATION_TYPE.MIN1, - notificationData: { - stateKey: 'nearDistributionEnd', - emitCondition ({ rootGetters }) { - const currentPeriod = rootGetters.groupSettings?.distributionDate - if (!currentPeriod) { return false } - - const nextPeriod = rootGetters.periodAfterPeriod(currentPeriod) - const now = dateToPeriodStamp(new Date()) - const comparison = comparePeriodStamps(nextPeriod, now) - - return rootGetters.ourGroupProfile?.incomeDetailsType === 'pledgeAmount' && - (comparison > 0 && comparison < DAYS_MILLIS * 7) && - (rootGetters.ourPayments && rootGetters.ourPayments.todo.length > 0) && - !myNotificationHas(item => item.type === 'NEAR_DISTRIBUTION_END' && item.data.period === currentPeriod) - }, - emit ({ rootState, rootGetters }) { - sbp('gi.notifications/emit', 'NEAR_DISTRIBUTION_END', { - groupID: rootState.currentGroupId, - period: rootGetters.groupSettings.distributionDate - }) - }, - shouldClearStateKey ({ rootGetters }) { - const currentPeriod = rootGetters.groupSettings.distributionDate - return rootGetters.currentNotifications.filter(item => item.type === 'NEAR_DISTRIBUTION_END') - .every(item => item.data.period !== currentPeriod) - } - } - }, - { - type: PERIODIC_NOTIFICATION_TYPE.MIN1, - notificationData: { - stateKey: 'nextDistributionPeriod', - emitCondition ({ rootGetters }) { - if (!rootGetters.ourGroupProfile?.incomeDetailsType) return false // if income-details are not set yet, ignore. - - const currentPeriod = rootGetters.groupSettings?.distributionDate - return currentPeriod && - comparePeriodStamps(dateToPeriodStamp(new Date()), currentPeriod) > 0 && - !myNotificationHas(item => item.type === 'NEW_DISTRIBUTION_PERIOD' && item.data.period === currentPeriod) - }, - emit ({ rootState, rootGetters }) { - sbp('gi.notifications/emit', 'NEW_DISTRIBUTION_PERIOD', { - groupID: rootState.currentGroupId, - period: rootGetters.groupSettings.distributionDate, - creatorID: rootGetters.ourIdentityContractId, - memberType: rootGetters.ourGroupProfile.incomeDetailsType === 'pledgeAmount' ? 'pledger' : 'receiver' - }) - }, - shouldClearStateKey ({ rootGetters }) { - return comparePeriodStamps(dateToPeriodStamp(new Date()), rootGetters.groupSettings.distributionDate) > 0 - } - } - }, - { - type: PERIODIC_NOTIFICATION_TYPE.MIN5, - notificationData: { - stateKey: 'expiringOrExpiredProposals', - emitCondition ({ rootGetters }) { - this.expiringOrExpiredProposalsByGroup = rootGetters.groupsByName.map(group => { - const { contractID } = group - const expiredProposalIds = [] - const expiringProposals = [] - const groupNotificationItems = [] - const groupProposals = rootGetters.groupProposals(contractID) || {} - - for (const proposalId in groupProposals) { - const proposal = groupProposals[proposalId] - if (proposal.status !== STATUS_OPEN) { continue } - - if (proposal.data.expires_date_ms < Date.now()) { // the proposal has already expired - expiredProposalIds.push(proposalId) - } else if (proposal.data.expires_date_ms < (Date.now() + DAYS_MILLIS)) { // the proposal is going to expire in next 24 hrs - if (!proposal.notifiedBeforeExpire) { // there is no group-chat notification sent for this proposal - expiringProposals.push(extractProposalData(proposal, { proposalId })) - } - - if (!Object.keys(proposal.votes).includes(rootGetters.ourIdentityContractId) && // check if the user hasn't voted for this proposal. - !myNotificationHas(item => item.type === 'PROPOSAL_EXPIRING' && item.data.proposalId === proposalId, contractID) // the user hasn't received the pop-up notification. - ) { - groupNotificationItems.push({ - proposalId, - creatorID: proposal.creatorID, - proposalType: proposal.data.proposalType, - proposalData: proposal.data.proposalData - }) - } - } - } - - return { contractID, expiringProposals, groupNotificationItems, expiredProposalIds } - }).filter(entry => entry.expiringProposals.length || entry.groupNotificationItems.length || entry.expiredProposalIds.length) - - return this.expiringOrExpiredProposalsByGroup.length - }, - async emit () { - for (const { contractID, expiringProposals, groupNotificationItems, expiredProposalIds } of this.expiringOrExpiredProposalsByGroup) { - if (expiringProposals.length) { - await sbp('gi.actions/group/notifyExpiringProposals', { - contractID, - data: { proposals: expiringProposals } - }) - } - - if (groupNotificationItems.length) { - groupNotificationItems.forEach(proposal => { - sbp('gi.notifications/emit', 'PROPOSAL_EXPIRING', { - groupID: contractID, - creatorID: proposal.creatorID, - proposalId: proposal.proposalId, - proposalType: proposal.proposalType, - proposalData: proposal.proposalData, - title: proposal.proposalType === PROPOSAL_GENERIC ? proposal.proposalData.name : '' - }) - }) - } - - if (expiredProposalIds.length) { - sbp('gi.actions/group/markProposalsExpired', { - contractID, - data: { proposalIds: expiredProposalIds } - }) - .catch((e) => { - console.error('Error calling markProposalsExpired from notifications mixin', e) - }) - } - } - }, - shouldClearStateKey: () => true - } - }, - { - type: PERIODIC_NOTIFICATION_TYPE.MIN5, - notificationData: { - stateKey: 'lastLoggedIn', - emitCondition ({ rootGetters }) { - return !!rootGetters.ourIdentityContractId - }, - emit ({ rootState, rootGetters }) { - Promise.all( - rootGetters.groupsByName.filter(({ active }) => active).map(({ contractID }) => { - return sbp('gi.actions/group/kv/updateLastLoggedIn', { - contractID, - throttle: true - }) - }) - ).catch((e) => { - console.error('Error updating lastLoggedIn', e) - }) - }, - shouldClearStateKey: () => true - } - }, // The following fixes a rare issue that we're not sure exactly why it happens. // Sometimes, the `namespace/lookup` call made as a side-effect in the identity // contract seems to fail. The result of this is that the corresponding cached @@ -262,16 +106,7 @@ const periodicNotificationEntries = [ const notificationMixin = { methods: { - getPendingQueuedInvocationsCount (): number { - return Object.entries(sbp('okTurtles.eventQueue/queuedInvocations')) - .flatMap(([, list]) => list).length - }, initOrResetPeriodicNotifications () { - if (this.getPendingQueuedInvocationsCount() > 0) { - setTimeout(() => this.initOrResetPeriodicNotifications(), 1000) - return - } - // make sure clear the states and timers for either previous user or previous group of the user, and re-init them. sbp('gi.periodicNotifications/clearStatesAndStopTimers') sbp('gi.periodicNotifications/init') diff --git a/frontend/model/notifications/mainPeriodicNotificationEntries.js b/frontend/model/notifications/mainPeriodicNotificationEntries.js new file mode 100644 index 0000000000..a6e61e1102 --- /dev/null +++ b/frontend/model/notifications/mainPeriodicNotificationEntries.js @@ -0,0 +1,225 @@ +import { PROPOSAL_GENERIC, STATUS_OPEN } from '@model/contracts/shared/constants.js' +import { DAYS_MILLIS, comparePeriodStamps, dateToPeriodStamp } from '@model/contracts/shared/time.js' +import sbp from '@sbp/sbp' +import { PERIODIC_NOTIFICATION_TYPE } from './periodicNotifications.js' +import { extractProposalData } from './utils.js' + +// util functions +const myNotificationHas = (checkFunc, groupId = '') => { + const myNotifications = groupId + ? sbp('state/vuex/getters').notificationsByGroup(groupId) + : sbp('state/vuex/getters').currentNotifications + + return myNotifications.some(item => checkFunc(item)) +} + +const periodicNotificationEntries: { + type: string; + notificationData: { + stateKey: string; + emitCondition: (arg: { rootState: Object, rootGetters: Object }) => boolean; + emit: (arg: { rootState: Object, rootGetters: Object }) => void | Promise; + shouldClearStateKey: (arg: { rootState: Object, rootGetters: Object }) => boolean; + } +}[] = [ + { + type: PERIODIC_NOTIFICATION_TYPE.MIN15, + notificationData: { + stateKey: 'nearDistributionEnd', + emitCondition ({ rootState, rootGetters }) { + const groupIds = rootGetters.ourGroups + + this.nearDistributionEnd = groupIds.map((gId) => { + const currentPeriod = rootGetters.groupSettingsForGroup(rootState[gId]).distributionDate + if (!currentPeriod) { return null } + + const nextPeriod = rootGetters.periodAfterPeriodForGroup(rootState[gId], currentPeriod) + const now = dateToPeriodStamp(new Date()) + const comparison = comparePeriodStamps(nextPeriod, now) + + return ( + rootGetters.ourGroupProfileForGroup(rootState[gId]).incomeDetailsType === 'pledgeAmount' && + (comparison > 0 && comparison < DAYS_MILLIS * 7) && + (rootGetters.ourPaymentsForGroup(rootState[gId])?.todo.length > 0) && + !myNotificationHas(item => item.type === 'NEAR_DISTRIBUTION_END' && item.data.period === currentPeriod, gId) + ) + ? [gId, currentPeriod] + : null + }).filter(Boolean) + + return (this.nearDistributionEnd.length > 0) + }, + emit () { + this.nearDistributionEnd.forEach(([groupID, period]) => { + sbp('gi.notifications/emit', 'NEAR_DISTRIBUTION_END', { + groupID, + period + }) + }) + }, + shouldClearStateKey ({ rootGetters }) { + const groupIds = rootGetters.ourGroups + + const groupedNotifications = rootGetters.notifications.filter(item => item.type === 'NEAR_DISTRIBUTION_END').reduce((acc, item) => { + if (!item.groupID) return acc + if (!acc[item.groupID]) acc[item.groupID] = [] + acc[item.groupID].push(item.data.period) + + return acc + }, Object.create(null)) + + return groupIds.every((groupId) => { + const currentPeriod = rootGetters.groupSettingsForGroup(groupId).distributionDate + return !!groupedNotifications[groupId]?.every(period => period !== currentPeriod) + }) + } + } + }, + { + type: PERIODIC_NOTIFICATION_TYPE.MIN5, + notificationData: { + stateKey: 'nextDistributionPeriod', + emitCondition ({ rootState, rootGetters }) { + const groupIds = rootGetters.ourGroups + + this.nextDistributionPeriod = groupIds.map((gId) => { + const profile = rootGetters.ourGroupProfileForGroup(rootState[gId]) + if (!profile.incomeDetailsType) { + // if income-details are not set yet, ignore. + return null + } + const currentPeriod = rootGetters.groupSettingsForGroup(rootState[gId]).distributionDate + if (!currentPeriod) { return null } + + return ( + comparePeriodStamps(dateToPeriodStamp(new Date()), currentPeriod) > 0 && + !myNotificationHas(item => item.type === 'NEW_DISTRIBUTION_PERIOD' && item.data.period === currentPeriod, gId) + ) + ? [gId, currentPeriod, profile.incomeDetailsType] + : null + }).filter(Boolean) + + return (this.nextDistributionPeriod.length > 0) + }, + emit ({ rootGetters }) { + const creatorID = rootGetters.ourIdentityContractId + this.nextDistributionPeriod.forEach(([groupID, period, incomeDetailsType]) => { + sbp('gi.notifications/emit', 'NEW_DISTRIBUTION_PERIOD', { + groupID, + period, + creatorID, + memberType: incomeDetailsType === 'pledgeAmount' ? 'pledger' : 'receiver' + }) + }) + }, + shouldClearStateKey ({ rootState, rootGetters }) { + const groupIds = rootGetters.ourGroups + + return groupIds.every((groupId) => { + return comparePeriodStamps(dateToPeriodStamp(new Date()), rootGetters.groupSettingsForGroup(rootState[groupId]).distributionDate) > 0 + }) + } + } + }, + { + type: PERIODIC_NOTIFICATION_TYPE.MIN5, + notificationData: { + stateKey: 'expiringOrExpiredProposals', + emitCondition ({ rootGetters }) { + this.expiringOrExpiredProposalsByGroup = rootGetters.groupsByName.map(group => { + const { contractID } = group + const expiredProposalIds = [] + const expiringProposals = [] + const groupNotificationItems = [] + const groupProposals = rootGetters.groupProposals(contractID) || {} + + for (const proposalId in groupProposals) { + const proposal = groupProposals[proposalId] + if (proposal.status !== STATUS_OPEN) { continue } + + if (proposal.data.expires_date_ms < Date.now()) { // the proposal has already expired + expiredProposalIds.push(proposalId) + } else if (proposal.data.expires_date_ms < (Date.now() + DAYS_MILLIS)) { // the proposal is going to expire in next 24 hrs + if (!proposal.notifiedBeforeExpire) { // there is no group-chat notification sent for this proposal + expiringProposals.push(extractProposalData(proposal, { proposalId })) + } + + if (!Object.keys(proposal.votes).includes(rootGetters.ourIdentityContractId) && // check if the user hasn't voted for this proposal. + !myNotificationHas(item => item.type === 'PROPOSAL_EXPIRING' && item.data.proposalId === proposalId, contractID) // the user hasn't received the pop-up notification. + ) { + groupNotificationItems.push({ + proposalId, + creatorID: proposal.creatorID, + proposalType: proposal.data.proposalType, + proposalData: proposal.data.proposalData + }) + } + } + } + + return { contractID, expiringProposals, groupNotificationItems, expiredProposalIds } + }).filter(entry => entry.expiringProposals.length || entry.groupNotificationItems.length || entry.expiredProposalIds.length) + + return this.expiringOrExpiredProposalsByGroup.length + }, + async emit () { + for (const { contractID, expiringProposals, groupNotificationItems, expiredProposalIds } of this.expiringOrExpiredProposalsByGroup) { + if (expiringProposals.length) { + await sbp('gi.actions/group/notifyExpiringProposals', { + contractID, + data: { proposals: expiringProposals } + }) + } + + if (groupNotificationItems.length) { + groupNotificationItems.forEach(proposal => { + sbp('gi.notifications/emit', 'PROPOSAL_EXPIRING', { + groupID: contractID, + creatorID: proposal.creatorID, + proposalId: proposal.proposalId, + proposalType: proposal.proposalType, + proposalData: proposal.proposalData, + title: proposal.proposalType === PROPOSAL_GENERIC ? proposal.proposalData.name : '' + }) + }) + } + + if (expiredProposalIds.length) { + sbp('gi.actions/group/markProposalsExpired', { + contractID, + data: { proposalIds: expiredProposalIds } + }) + .catch((e) => { + console.error('Error calling markProposalsExpired from notifications mixin', e) + }) + } + } + }, + shouldClearStateKey: () => true + } + }, + { + type: PERIODIC_NOTIFICATION_TYPE.MIN5, + notificationData: { + stateKey: 'lastLoggedIn', + emitCondition ({ rootGetters }) { + return !!rootGetters.ourIdentityContractId + }, + emit ({ rootState, rootGetters }) { + Promise.all( + rootGetters.groupsByName.filter(({ active }) => active).map(({ contractID }) => { + return sbp('gi.actions/group/kv/updateLastLoggedIn', { + contractID, + throttle: true + }) + }) + ).catch((e) => { + console.error('Error updating lastLoggedIn', e) + }) + }, + shouldClearStateKey: () => true + } + } +] + +export default periodicNotificationEntries diff --git a/frontend/model/notifications/messageReceivePostEffect.js b/frontend/model/notifications/messageReceivePostEffect.js index d128128c78..8eed33e0b9 100644 --- a/frontend/model/notifications/messageReceivePostEffect.js +++ b/frontend/model/notifications/messageReceivePostEffect.js @@ -66,6 +66,8 @@ async function messageReceivePostEffect ({ ? rootGetters.userDisplayNameFromID(identityContractID) : partners.map(cID => rootGetters.userDisplayNameFromID(cID)).join(', ') icon = rootGetters.ourContactProfilesById[lastJoinedPartner]?.picture + } else { + icon = rootGetters.ourContactProfilesById[memberID]?.picture } const path = `/group-chat/${contractID}` diff --git a/frontend/model/notifications/nativeNotification.js b/frontend/model/notifications/nativeNotification.js index a484dcdd9c..6a595452e6 100644 --- a/frontend/model/notifications/nativeNotification.js +++ b/frontend/model/notifications/nativeNotification.js @@ -82,10 +82,23 @@ export async function requestNotificationPermission (): Promise { } } -export function makeNotification ({ title, body, icon, path }: { - title: string, body: string, icon?: string, path?: string -}): void | Promise { +// eslint-disable-next-line require-await +export async function makeNotification ({ title, body, icon, path, groupID, sbpInvocation }: { + title: string, body: string, icon?: string, path?: string, groupID?: string, + sbpInvocation?: any[] +}): Promise { if (typeof Notification !== 'function') return + if (typeof icon === 'object' && icon.manifestCid) { + // We only use cached files to render notifications as quickly as possible + const cachedArrayBuffer = await sbp('gi.db/filesCache/load', icon.manifestCid).catch((e) => { + console.error('[Avatar.vue] Error loading file from cache', e) + }) + if (cachedArrayBuffer) { + // We use `data:` URLs because the SW is unable to create `blob:` URLs + icon = 'data:;base64,' + encodeURIComponent(Buffer.from(cachedArrayBuffer).toString('base64')) + } + } + // If not running on a SW if (typeof WorkerGlobalScope !== 'function') { try { @@ -102,17 +115,19 @@ export function makeNotification ({ title, body, icon, path }: { } catch (e) { return navigator.serviceWorker?.ready.then(registration => { // $FlowFixMe - return registration.showNotification(title, { body, icon, data: { path } }) + return registration.showNotification(title, { body, icon, data: { groupID, path, sbpInvocation } }) }).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 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) + return self.registration.showNotification(title, + { body, icon, data: { groupID, path, sbpInvocation } } + ).catch(console.warn) }) } } diff --git a/frontend/model/notifications/periodicNotifications.js b/frontend/model/notifications/periodicNotifications.js index 623b36f5a2..0124382986 100644 --- a/frontend/model/notifications/periodicNotifications.js +++ b/frontend/model/notifications/periodicNotifications.js @@ -1,11 +1,17 @@ 'use strict' import sbp from '@sbp/sbp' -import Vue from 'vue' // $FlowFixMe import { isFunction, objectOf, string } from '@model/contracts/misc/flowTyper.js' import { MINS_MILLIS } from '@model/contracts/shared/time.js' +// This file is used in both the SW and window contexts. +// We do not import Vue for two reasons: +// (1) it's bulk that's not needed in the SW, and +// (2) there is no place in the app to re-render in response to state updates +// in `alreadyFired` and `lastRun` + +let shortestDelay: number export const PERIODIC_NOTIFICATION_TYPE = { MIN1: '1MIN', MIN5: '5MIN', @@ -13,16 +19,17 @@ export const PERIODIC_NOTIFICATION_TYPE = { MIN30: '30MIN' } -const every1MinTimeout = { notifications: [], state: {}, delay: MINS_MILLIS } -const every5MinTimeout = { notifications: [], state: {}, delay: 5 * MINS_MILLIS } -const every15MinTimeout = { notifications: [], state: {}, delay: 15 * MINS_MILLIS } -const every30MinTimeout = { notifications: [], state: {}, delay: 30 * MINS_MILLIS } +const ephemeralNotificationState: { + notifications: any[], partition: Object, clearTimeout?: Function +} = { + notifications: [], partition: Object.create(null) +} -const typeToObjectMap = { - [PERIODIC_NOTIFICATION_TYPE.MIN1]: every1MinTimeout, - [PERIODIC_NOTIFICATION_TYPE.MIN5]: every5MinTimeout, - [PERIODIC_NOTIFICATION_TYPE.MIN15]: every15MinTimeout, - [PERIODIC_NOTIFICATION_TYPE.MIN30]: every30MinTimeout +const typeToDelayMap = { + [PERIODIC_NOTIFICATION_TYPE.MIN1]: 1 * MINS_MILLIS, + [PERIODIC_NOTIFICATION_TYPE.MIN5]: 5 * MINS_MILLIS, + [PERIODIC_NOTIFICATION_TYPE.MIN15]: 15 * MINS_MILLIS, + [PERIODIC_NOTIFICATION_TYPE.MIN30]: 30 * MINS_MILLIS } const validateNotificationData = objectOf({ @@ -38,59 +45,150 @@ const validateNotificationData = objectOf({ shouldClearStateKey: isFunction }) -async function runNotificationListRecursive (data) { +/** + * Runs a recursive notification list handler that checks and emits + * notifications based on specified conditions and delays. + * + * The function performs the following steps: + * 1. Checks if enough time has passed since the last run based on the specified + * delay. + * 2. Iterates over each notification entry in the `data.notifications` array: + * - If the notification has not been fired and its emit condition evaluates + * to true, it: + * - Calls the emit function associated with the notification. + * - Marks the notification as fired in the `firedMap`. + * - If the notification has been fired and its clear condition evaluates to + * true, it: + * - Removes the notification from the `firedMap`. + * 3. Updates the `lastRun` timestamp to the current time. + * 4. Sets a timeout to recursively call `runNotificationListRecursive` after + * the specified delay, adjusting for the time that has already passed since the + * last run. + */ +async function runNotificationListRecursive () { const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') - const firedMap = rootState.periodicNotificationAlreadyFiredMap - const callWithStates = func => func.call(data.state, { rootState, rootGetters }) + const firedMap = rootState.periodicNotificationAlreadyFiredMap.alreadyFired + const lastRunMap = rootState.periodicNotificationAlreadyFiredMap.lastRun + const callWithStates = (func, stateKey) => func.call(ephemeralNotificationState.partition[stateKey], { rootState, rootGetters }) - for (const entry of data.notifications) { - if (!firedMap[entry.stateKey] && callWithStates(entry.emitCondition)) { - await callWithStates(entry.emit) - Vue.set(firedMap, entry.stateKey, true) - } + // Exit if a timeout is already set + if (ephemeralNotificationState.clearTimeout) return + // Prevent accidentally calling `runNotificationListRecursive` while it's + // still running + // We set `clearTimeout` to a local value to stop processing notifications + // when clearing stop notifications, to avoid calling + // `runNotificationListRecursive` while it's running and to be able to compare + // it to the local value later to replace `clearTimeout` later on in this + // function + let aborted: boolean = false + const abort = () => { aborted = true } + ephemeralNotificationState.clearTimeout = abort + + // If there are any queued invocations, wait until they're done + // This is needed because some of the notifications might need contract state + // to be ready. By waiting on all queues, we ensure that we have the latest + // state after setting up Chelonia. When periodic notifications were used + // only in the browser window (instead of also in the SW), a timeout was used + // for a similar effect. + await Promise.all( + ((Object.entries(sbp('okTurtles.eventQueue/queuedInvocations')): any): [string, (Function | string[])[]]) + .map(([queue, invocations]) => { + return !!invocations.length && sbp('okTurtles.eventQueue/queueEvent', queue, () => {}).catch(() => {}) + }) + ) + + // Check if enough time has passed since the last run + for (const entry of ephemeralNotificationState.notifications) { + if (aborted) break + const stateKey = entry.stateKey + const lastRun = lastRunMap[stateKey] || 0 + // Use the delay from the notification entry + const delay = entry.delay + if ((Date.now() - lastRun) >= delay) { + try { + if (!firedMap[stateKey] && callWithStates(entry.emitCondition, stateKey)) { + await callWithStates(entry.emit, stateKey) + firedMap[stateKey] = true + } + + if (firedMap[stateKey] && callWithStates(entry.shouldClearStateKey, stateKey)) { + delete firedMap[stateKey] + } + } catch (e) { + console.error('runNotificationListRecursive: Error calling notification', stateKey, e) + } - if (firedMap[entry.stateKey] && callWithStates(entry.shouldClearStateKey)) { - Vue.delete(firedMap, entry.stateKey) + // Update the last run timestamp + lastRunMap[stateKey] = Date.now() } } - data.state.timeoutId = setTimeout(() => runNotificationListRecursive(data), data.delay) + // Set a timeout for the next run of the notification check + // But only do so if `clearTimeout` hasn't been reset + if (ephemeralNotificationState.clearTimeout !== abort) return + ephemeralNotificationState.clearTimeout = (() => { + const timeoutId = setTimeout( + () => { + delete ephemeralNotificationState.clearTimeout + runNotificationListRecursive() + }, + shortestDelay + ) + return () => clearTimeout(timeoutId) + })() } -function clearTimeoutObject (data) { - clearTimeout(data.state.timeoutId) - data.state = {} +function clearTimeoutObject () { + if (ephemeralNotificationState.clearTimeout) { + ephemeralNotificationState.clearTimeout() + delete ephemeralNotificationState.clearTimeout + } + + ephemeralNotificationState.notifications.forEach(({ stateKey }) => { + ephemeralNotificationState.partition[stateKey] = Object.create(null) + }) } sbp('sbp/selectors/register', { 'gi.periodicNotifications/init': function () { - runNotificationListRecursive(every1MinTimeout) - runNotificationListRecursive(every5MinTimeout) - runNotificationListRecursive(every15MinTimeout) - runNotificationListRecursive(every30MinTimeout) + return runNotificationListRecursive().catch((e) => { + console.error('[gi.periodicNotifications/init] Error', e) + }) }, 'gi.periodicNotifications/clearStatesAndStopTimers': function () { const rootState = sbp('state/vuex/state') - Vue.set(rootState, 'periodicNotificationAlreadyFiredMap', {}) - clearTimeoutObject(every1MinTimeout) - clearTimeoutObject(every5MinTimeout) - clearTimeoutObject(every15MinTimeout) - clearTimeoutObject(every30MinTimeout) + rootState.periodicNotificationAlreadyFiredMap = { + alreadyFired: Object.create(null), // { notificationKey: boolean }, + lastRun: Object.create(null) // { notificationKey: number }, + } + clearTimeoutObject() }, 'gi.periodicNotifications/importNotifications': function (entries) { const keySet = new Set() for (const { type, notificationData } of entries) { if (!type || !notificationData) throw new Error('A required field in a periodic notification entry is missing.') + + const delay = typeToDelayMap[type] + if (!delay) { + throw new RangeError('Invalid delay') + } + // Using inverted logic because `shortestDelay` could be undefined (on + // the first call to this function) and `undefined` is coerced to NaN. + if (!(shortestDelay <= delay)) shortestDelay = delay + validateNotificationData(notificationData) if (keySet.has(notificationData.stateKey)) { throw new Error('Duplicate periodic notification state key: ' + notificationData.stateKey) } keySet.add(notificationData.stateKey) - const notificationList = typeToObjectMap[type].notifications - notificationList.push(notificationData) + ephemeralNotificationState.partition[notificationData.stateKey] = Object.create(null) + ephemeralNotificationState.notifications.push({ + ...notificationData, + delay + }) } } }) diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index dda04d7f3b..6f39449e1c 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -26,27 +26,42 @@ export default ({ action = value.action } const state = sbp('state/vuex/state') - let who + let who, plaintextWho if (message.innerSigningKeyId()) { const innerSigningContractID = findContractIDByForeignKeyId(state[message.contractID()], message.innerSigningKeyId()) - who = innerSigningContractID && `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${innerSigningContractID}` + if (innerSigningContractID) { + who = `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${innerSigningContractID}` + plaintextWho = sbp('state/vuex/getters').userDisplayNameFromID(innerSigningContractID) + } } - const Lparams = { - ...LTags('b'), + const LcommonParams = { errName: error.name, activity, action: action ?? opType, - contract: state.contracts[contractID]?.type ?? contractID, - who, - errMsg: error.message ?? '?' + contract: state.contracts[contractID]?.type ?? contractID + } + + const Lparams = { + ...LcommonParams, + ...LTags('b'), + who + } + + const LplaintextParams = { + ...LcommonParams, + who: plaintextWho } return { + title: L('Internal error'), body: who ? L("{errName} during {activity} for '{action}' from {b_}{who}{_b} to '{contract}': '{errMsg}'", Lparams) : L("{errName} during {activity} for '{action}' to '{contract}': '{errMsg}'", Lparams), + plaintextBody: who + ? L("{errName} during {activity} for '{action}' from {who} to '{contract}': '{errMsg}'", LplaintextParams) + : L("{errName} during {activity} for '{action}' to '{contract}': '{errMsg}'", LplaintextParams), icon: 'exclamation-triangle', level: 'danger', linkTo: `/app/dashboard?modal=UserSettingsModal&tab=application-logs&errorMsg=${encodeURIComponent(error.message)}`, @@ -55,7 +70,9 @@ export default ({ }, GENERAL (data: { contractID: string, message: string }) { return { + title: L('Group Income'), body: data.message, + plaintextBody: data.message, icon: 'cog', level: 'info', linkTo: '', @@ -64,7 +81,9 @@ export default ({ }, WARNING (data: { contractID: string, message: string }) { return { + title: L('Warning'), body: data.message, + plaintextBody: data.message, icon: 'exclamation-triangle', level: 'danger', linkTo: '', @@ -73,7 +92,9 @@ export default ({ }, ERROR (data: { contractID: string, message: string }) { return { + title: L('Error'), body: data.message, + plaintextBody: data.message, icon: 'exclamation-triangle', level: 'danger', linkTo: `/app/dashboard?modal=UserSettingsModal&tab=application-logs&errorMsg=${encodeURIComponent(data.message)}`, @@ -82,9 +103,14 @@ export default ({ }, CONTRIBUTION_REMINDER (data: { date: string }) { return { + title: L('Contribution reminder'), body: L('Do not forget to send your pledge by {strong_}{date}{_strong}.', { + date: data.date, ...LTags('strong') }), + plaintextBody: L('Do not forget to send your pledge by {strong_}{date}{_strong}.', { + date: data.date + }), icon: 'coins', level: 'info', linkTo: '/payments', @@ -93,9 +119,13 @@ export default ({ }, INCOME_DETAILS_OLD (data: { months: number, lastUpdatedDate: string }) { return { + title: L('Update income details'), body: L("You haven't updated your income details in more than {months} months. Would you like to review them now?", { months: Math.floor(data.months) // Avoid displaying decimals }), + plaintextBody: L("You haven't updated your income details in more than {months} months. Would you like to review them now?", { + months: Math.floor(data.months) // Avoid displaying decimals + }), icon: 'coins', level: 'info', linkTo: '/contributions?modal=IncomeDetails', @@ -107,49 +137,70 @@ export default ({ const rootState = sbp('state/vuex/state') return { avatarUserID: data.memberID, + title: rootState[data.groupID]?.settings?.groupName || L('Member added'), body: L('The group has a new member. Say hi to {strong_}{name}{_strong}!', { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.memberID}`, ...LTags('strong') }), + plaintextBody: L('The group has a new member. Say hi to {strong_}{name}{_strong}!', { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.memberID) + }), icon: 'user-plus', level: 'info', linkTo: `/group-chat/${rootState[data.groupID]?.generalChatRoomId}`, - scope: 'group' + scope: 'group', + groupID: data.groupID } }, MEMBER_LEFT (data: { groupID: string, memberID: string }) { + const rootState = sbp('state/vuex/state') return { + title: rootState[data.groupID]?.settings?.groupName || L('Member added'), avatarUserID: data.memberID, body: L('{strong_}{name}{_strong} has left your group. Contributions were updated accordingly.', { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.memberID}`, ...LTags('strong') }), + plaintextBody: L('{strong_}{name}{_strong} has left your group. Contributions were updated accordingly.', { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.memberID) + }), icon: 'user-minus', level: 'danger', linkTo: '/contributions', - scope: 'group' + scope: 'group', + groupID: data.groupID } }, MEMBER_REMOVED (data: { groupID: string, memberID: string }) { + const rootState = sbp('state/vuex/state') return { + title: rootState[data.groupID]?.settings?.groupName || L('Member added'), avatarUserID: data.memberID, // REVIEW @mmbotelho - Not only contributions, but also proposals. body: L('{strong_}{name}{_strong} was kicked out of the group. Contributions were updated accordingly.', { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.memberID}`, ...LTags('strong') }), + plaintextBody: L('{strong_}{name}{_strong} was kicked out of the group. Contributions were updated accordingly.', { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.memberID) + }), icon: 'user-minus', level: 'danger', linkTo: '/contributions', - scope: 'group' + scope: 'group', + groupID: data.groupID } }, NEW_PROPOSAL (data: { groupID: string, creatorID: string, proposalHash: string, subtype: NewProposalType }) { + const rootState = sbp('state/vuex/state') const isCreator = data.creatorID === sbp('state/vuex/getters').ourIdentityContractId // notification message is different for creator and non-creator. const args = { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.creatorID}`, ...LTags('strong') } + const plaintextArgs = { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.creatorID) + } const bodyTemplateMap = { ADD_MEMBER: isCreator @@ -172,6 +223,27 @@ export default ({ : L('{strong_}{name}{_strong} created a proposal. Vote now!', args) } + const plaintextBodyTemplateMap = { + ADD_MEMBER: isCreator + ? L('You proposed to add a member to the group.') + : L('{strong_}{name}{_strong} proposed to add a member to the group. Vote now!', plaintextArgs), + CHANGE_MINCOME: isCreator + ? L('You proposed to change the group mincome.') + : L('{strong_}{name}{_strong} proposed to change the group mincome. Vote now!', plaintextArgs), + CHANGE_DISTRIBUTION_DATE: isCreator + ? L('You proposed to change the group distribution date.') + : L('{strong_}{name}{_strong} proposed to change the group distribution date. Vote now!', plaintextArgs), + CHANGE_VOTING_RULE: isCreator + ? L('You proposed to change the group voting system.') + : L('{strong_}{name}{_strong} proposed to change the group voting system. Vote now!', plaintextArgs), + REMOVE_MEMBER: isCreator + ? L('You proposed to remove a member from the group.') + : L('{strong_}{name}{_strong} proposed to remove a member from the group. Vote now!', plaintextArgs), + GENERIC: isCreator + ? L('You created a proposal.') + : L('{strong_}{name}{_strong} created a proposal. Vote now!', plaintextArgs) + } + const iconMap = { ADD_MEMBER: 'user-plus', CHANGE_MINCOME: 'dollar-sign', @@ -182,20 +254,23 @@ export default ({ } return { + title: rootState[data.groupID]?.settings?.groupName || L('New proposal'), avatarUserID: data.creatorID, body: bodyTemplateMap[data.subtype], + plaintextBody: plaintextBodyTemplateMap[data.subtype], creatorID: data.creatorID, icon: iconMap[data.subtype], level: 'info', subtype: data.subtype, scope: 'group', - sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { - contractID: sbp('state/vuex/state').currentGroupId, + sbpInvocation: ['gi.app/group/checkAndSeeProposal', { + contractID: data.groupID, data: { proposalHash: data.proposalHash } }] } }, - PROPOSAL_EXPIRING (data: { proposalId: string, proposal: Object }) { + PROPOSAL_EXPIRING (data: { groupID: string, proposalId: string, proposal: Object }) { + const rootState = sbp('state/vuex/state') const { proposalData, proposalType } = data.proposal.data const typeToTitleMap = { [PROPOSAL_INVITE_MEMBER]: L('Member addition'), @@ -209,22 +284,27 @@ export default ({ } return { + title: rootState[data.groupID]?.settings?.groupName || L('Proposal expiring'), avatarUserID: '', body: L('Proposal about to expire: {i_}"{proposalTitle}"{_i}. Please vote!', { ...LTags('i'), proposalTitle: typeToTitleMap[proposalType] }), + plaintextBody: L('Proposal about to expire: {i_}"{proposalTitle}"{_i}. Please vote!', { + proposalTitle: typeToTitleMap[proposalType] + }), level: 'info', icon: 'exclamation-triangle', scope: 'group', data: { proposalId: data.proposalId }, - sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { - contractID: sbp('state/vuex/state').currentGroupId, + sbpInvocation: ['gi.app/group/checkAndSeeProposal', { + contractID: data.groupID, data: { proposalHash: data.proposalId } }] } }, - PROPOSAL_CLOSED (data: { proposal: Object, proposalHash: string }) { + PROPOSAL_CLOSED (data: { groupID: string, proposal: Object, proposalHash: string }) { + const rootState = sbp('state/vuex/state') const { creatorID, status, type, options } = getProposalDetails(data.proposal) const isCreator = creatorID === sbp('state/vuex/getters').ourIdentityContractId // notification message is different for creator and non-creator @@ -241,10 +321,17 @@ export default ({ name: !isCreator ? `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${creatorID}` : '' } + const plaintextArgs = { + ...options, + closedWith: statusMap[status].closedWith, + name: !isCreator ? sbp('state/vuex/getters').userDisplayNameFromID(creatorID) : '' + } + if (options.memberID) { // NOTE: replace member with their mention when their contractID is provided // e.g., when the type is PROPOSAL_REMOVE_MEMBER args['member'] = `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${options.memberID}` + plaintextArgs['member'] = sbp('state/vuex/getters').userDisplayNameFromID(options.memberID) } const bodyTemplateMap = { @@ -265,58 +352,94 @@ export default ({ : L("{strong_}{name}'s{_strong} proposal \"{title}\" was {strong_}{closedWith}{_strong}.", args) } + const plaintextBodyTemplateMap = { + [PROPOSAL_INVITE_MEMBER]: isCreator + ? L('Your proposal to add {member} to the group was {strong_}{closedWith}{_strong}.', plaintextArgs) + : L("{strong_}{name}'s{_strong} proposal to add {member} to the group was {strong_}{closedWith}{_strong}.", plaintextArgs), + [PROPOSAL_REMOVE_MEMBER]: isCreator + ? L('Your proposal to remove {member} from the group was {strong_}{closedWith}{_strong}.', plaintextArgs) + : L("{strong_}{name}'s{_strong} proposal to remove {member} from the group was {strong_}{closedWith}{_strong}.", plaintextArgs), + [PROPOSAL_GROUP_SETTING_CHANGE]: isCreator + ? L('Your proposal to change group\'s {setting} to {value} was {strong_}{closedWith}{_strong}.', plaintextArgs) + : L("{strong_}{name}'s{_strong} proposal to change group's {setting} to {value} was {strong_}{closedWith}{_strong}.", plaintextArgs), + [PROPOSAL_PROPOSAL_SETTING_CHANGE]: isCreator + ? L('Your proposal to change group\'s {setting} was {strong_}{closedWith}{_strong}.', plaintextArgs) + : L("{strong_}{name}'s{_strong} proposal to change group's {setting} was {strong_}{closedWith}{_strong}.", plaintextArgs), + [PROPOSAL_GENERIC]: isCreator + ? L('Your proposal "{title}" was {strong_}{closedWith}{_strong}.', plaintextArgs) + : L("{strong_}{name}'s{_strong} proposal \"{title}\" was {strong_}{closedWith}{_strong}.", plaintextArgs) + } + return { + title: rootState[data.groupID]?.settings?.groupName || L('Proposal closed'), avatarUserID: creatorID, body: bodyTemplateMap[type], + plaintextBody: plaintextBodyTemplateMap[type], icon: statusMap[status].icon, level: statusMap[status].level, scope: 'group', - sbpInvocation: ['gi.actions/group/checkAndSeeProposal', { - contractID: sbp('state/vuex/state').currentGroupId, + sbpInvocation: ['gi.app/group/checkAndSeeProposal', { + contractID: data.groupID, data: { proposalHash: data.proposalHash } }] } }, - PAYMENT_RECEIVED (data: { creatorID: string, amount: string, paymentHash: string }) { + PAYMENT_RECEIVED (data: { groupID: string, creatorID: string, amount: string, paymentHash: string }) { + const rootState = sbp('state/vuex/state') return { + title: rootState[data.groupID]?.settings?.groupName || L('Payment received'), avatarUserID: data.creatorID, body: L('{strong_}{name}{_strong} sent you a {amount} mincome contribution. {strong_}Review and send a thank you note.{_strong}', { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.creatorID}`, amount: data.amount, ...LTags('strong') }), + plaintextBody: L('{strong_}{name}{_strong} sent you a {amount} mincome contribution. {strong_}Review and send a thank you note.{_strong}', { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.creatorID), + amount: data.amount + }), creatorID: data.creatorID, icon: '', level: 'info', linkTo: `/payments?modal=PaymentDetail&id=${data.paymentHash}`, - scope: 'group' + scope: 'group', + groupID: data.groupID } }, - PAYMENT_THANKYOU_SENT (data: { creatorID: string, fromMemberID: string, toMemberID: string }) { + PAYMENT_THANKYOU_SENT (data: { groupID: string, creatorID: string, fromMemberID: string, toMemberID: string }) { + const rootState = sbp('state/vuex/state') return { + title: rootState[data.groupID]?.settings?.groupName || L('Thank you note received'), avatarUserID: data.fromMemberID, body: L('{strong_}{name}{_strong} sent you a {strong_}thank you note{_strong} for your contribution.', { name: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.fromMemberID}`, ...LTags('strong') }), + plaintextBody: L('{strong_}{name}{_strong} sent you a {strong_}thank you note{_strong} for your contribution.', { + name: sbp('state/vuex/getters').userDisplayNameFromID(data.fromMemberID) + }), creatorID: data.fromMemberID, icon: '', level: 'info', linkTo: `/payments?modal=ThankYouNoteModal&from=${data.fromMemberID}&to=${data.toMemberID}`, - scope: 'group' + scope: 'group', + groupID: data.groupID } }, - MINCOME_CHANGED (data: { creatorID: string, to: number, memberType: string, increased: boolean }) { + MINCOME_CHANGED (data: { groupID: string, creatorID: string, to: number, memberType: string, increased: boolean }) { + const rootState = sbp('state/vuex/state') const { withGroupCurrency } = sbp('state/vuex/getters') return { + title: rootState[data.groupID]?.settings?.groupName || L('Mincome changes'), avatarUserID: data.creatorID, body: L('The mincome has changed to {amount}.', { amount: withGroupCurrency(data.to) }), + plaintextBody: L('The mincome has changed to {amount}.', { amount: withGroupCurrency(data.to) }), creatorID: data.creatorID, icon: 'dollar-sign', level: 'info', scope: 'group', - sbpInvocation: ['gi.actions/group/displayMincomeChangedPrompt', { - contractID: sbp('state/vuex/state').currentGroupId, + sbpInvocation: ['gi.app/group/displayMincomeChangedPrompt', { + contractID: data.groupID, data: { amount: data.to, memberType: data.memberType, @@ -325,7 +448,8 @@ export default ({ }] } }, - NEW_DISTRIBUTION_PERIOD (data: { period: string, creatorID: string, memberType: string }) { + NEW_DISTRIBUTION_PERIOD (data: { groupID: string, period: string, creatorID: string, memberType: string }) { + const rootState = sbp('state/vuex/state') const { period, creatorID, memberType } = data const periodDisplay = humanDate(period, { month: 'short', day: 'numeric', year: 'numeric' }) const bodyTemplate = { @@ -335,31 +459,40 @@ export default ({ } return { + title: rootState[data.groupID]?.settings?.groupName || L('New distribution period'), avatarUserID: creatorID, body: bodyTemplate[memberType], + plaintextBody: bodyTemplate[memberType], level: 'info', icon: 'coins', linkTo: memberType === 'pledger' ? '/payments' : '/contributions?modal=IncomeDetails', scope: 'group', - data: { period } // is used to check if a notification has already been sent for a particular dist-period + data: { period }, // is used to check if a notification has already been sent for a particular dist-period + groupID: data.groupID } }, - NEAR_DISTRIBUTION_END (data: { period: string }) { + NEAR_DISTRIBUTION_END (data: { groupID: string, period: string }) { + const rootState = sbp('state/vuex/state') return { + title: rootState[data.groupID]?.settings?.groupName || L('Distribution period ends soon'), body: L("Less than 1 week left before the distribution period ends - don't forget to send payments!"), + plaintextBody: L("Less than 1 week left before the distribution period ends - don't forget to send payments!"), level: 'info', icon: 'coins', linkTo: '/payments', scope: 'group', - data + data, + groupID: data.groupID } }, NONMONETARY_CONTRIBUTION_UPDATE ( data: { + groupID: string, creatorID: string, updateData: { prev: any[], after: any[] } } ) { + const rootState = sbp('state/vuex/state') const { prev, after } = data.updateData const added = after.filter(v => !prev.includes(v)) const removed = prev.filter(v => !after.includes(v)) @@ -376,6 +509,7 @@ export default ({ } const name = `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${data.creatorID}` + const plaintextName = sbp('state/vuex/getters').userDisplayNameFromID(data.creatorID) const contributionsFormatted = (entries) => { const first = entries[0] const len = entries.length @@ -393,13 +527,25 @@ export default ({ ...LTags('strong') }) } + const plaintextBodyContentMap = { + added: () => L('{name} added non-monetary contribution: {strong_}{added}{_strong}', { name, added: contributionsFormatted(added) }), + removed: () => L('{name} removed non-monetary contribution: {strong_}{removed}{_strong}', { name, removed: contributionsFormatted(removed) }), + updated: () => L('{name} updated non-monetary contribution: added {strong_}{added}{_strong} and removed {strong_}{removed}{_strong}', { + name: plaintextName, + added: contributionsFormatted(added), + removed: contributionsFormatted(removed) + }) + } return { + title: rootState[data.groupID]?.settings?.groupName || L('Non-monetary contribution updated'), body: bodyContentMap[updateType](), + plaintextBody: plaintextBodyContentMap[updateType](), scope: 'group', avatarUserID: data.creatorID, level: 'info', icon: 'chart-pie', - linkTo: '/contributions' + linkTo: '/contributions', + groupID: data.groupID } } }: { [key: string]: ((data: Object) => NotificationTemplate) }) diff --git a/frontend/model/notifications/types.flow.js b/frontend/model/notifications/types.flow.js index 18f1c61498..f4c60774b9 100644 --- a/frontend/model/notifications/types.flow.js +++ b/frontend/model/notifications/types.flow.js @@ -10,9 +10,13 @@ export type NewProposalType = export type Notification = { +hash: string; + // Native notification title + +title: string; // Indicates which user avatar icon to display alongside the notification. +avatarUserID: string; +body: string; + // Body without markup to use in native notifications + +plaintextBody: string; // If present, indicates in which group's notification list to display the notification. +groupID?: string; +icon: string; diff --git a/frontend/model/notifications/vuexModule.js b/frontend/model/notifications/vuexModule.js index 51def7fd2f..61ed35ae49 100644 --- a/frontend/model/notifications/vuexModule.js +++ b/frontend/model/notifications/vuexModule.js @@ -3,11 +3,11 @@ import sbp from '@sbp/sbp' import Vue from 'vue' import { cloneDeep } from '~/frontend/model/contracts/shared/giLodash.js' +import { NOTIFICATION_EMITTED, NOTIFICATION_REMOVED, NOTIFICATION_STATUS_LOADED } from '~/frontend/utils/events.js' +import getters from './getters.js' import * as keys from './mutationKeys.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' -import { NOTIFICATION_EMITTED, NOTIFICATION_REMOVED, NOTIFICATION_STATUS_LOADED } from '~/frontend/utils/events.js' +import { compareOnTimestamp } from './utils.js' sbp('okTurtles.events/on', NOTIFICATION_EMITTED, (notification) => { sbp('state/vuex/commit', keys.ADD_NOTIFICATION, notification) @@ -25,83 +25,6 @@ const defaultState = { items: [], status: {} } -const getters = { - notifications (state, getters, rootState) { - return state.items.map(item => { - const notification = { ...item, ...state.status[item.hash] } - // Notifications older than MAX_AGE_UNREAD are discarded - if (age(notification) > MAX_AGE_UNREAD) { - return null - } else if (!notification.read && age(notification) > MAX_AGE_READ) { - // Unread notifications older than MAX_AGE_READ are automatically - // marked as read - notification.read = true - } - return notification - }).filter(Boolean) - }, - // Notifications relevant to the current group only. - currentGroupNotifications (state, getters, rootState) { - return getters.notifications.filter(item => item.groupID === rootState.currentGroupId) - }, - - // Notifications relevant to a specific group. - notificationsByGroup (state, getters) { - return groupID => getters.notifications.filter(item => item.groupID === groupID) - }, - - currentGroupUnreadNotificationCount (state, getters) { - return getters.currentGroupUnreadNotifications.length - }, - - // Unread notifications relevant to the current group only. - currentGroupUnreadNotifications (state, getters, rootState) { - return getters.currentGroupNotifications.filter(item => !item.read) - }, - - currentNewNotifications (state, getters) { - return getters.currentNotifications.filter(isNew) - }, - - currentNotificationCount (state, getters) { - return getters.currentNotifications.length - }, - - // Notifications relevant to the current group, plus notifications that don't belong to any group in particular. - currentNotifications (state, getters, rootState) { - return getters.notifications.filter(item => !item.groupID || item.groupID === rootState.currentGroupId) - }, - - currentOlderNotifications (state, getters) { - return getters.currentNotifications.filter(isOlder) - }, - - currentUnreadNotificationCount (state, getters) { - return getters.currentNotifications.filter(item => !item.read).length - }, - - currentUnreadNotifications (state, getters) { - return getters.currentNotifications.filter(item => !item.read) - }, - - totalUnreadNotificationCount (state, getters) { - return getters.notifications.filter(item => !item.read).length - }, - - // Finds what number to display on a group's avatar badge in the sidebar. Used in GroupsList.vue. - unreadGroupNotificationCountFor (state, getters) { - return (groupID) => getters.unreadGroupNotificationsFor(groupID).length - }, - - unreadGroupNotificationsFor (state, getters, rootState) { - return (groupID) => ( - groupID === rootState.currentGroupId - ? getters.currentGroupUnreadNotifications - : getters.notifications.filter(item => !item.read && item.groupID === groupID) - ) - } -} - const mutations = { // Seems necessary because the red badge would not clear upon signing up a new user in Cypress via the bypassUI mechanism. logout (state) { diff --git a/frontend/model/state.js b/frontend/model/state.js index 42d2e72e7a..dfc8681f9f 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -82,10 +82,13 @@ const initialState = { loggedIn: false, // false | { username: string, identityContractID: string } namespaceLookups: Object.create(null), // { [username]: sbp('namespace/lookup') } reverseNamespaceLookups: Object.create(null), // { [contractID]: username } - periodicNotificationAlreadyFiredMap: {}, // { notificationKey: boolean }, contractSigningKeys: Object.create(null), lastLoggedIn: {}, // Group last logged in information - preferences: {} + preferences: {}, + periodicNotificationAlreadyFiredMap: { + alreadyFired: Object.create(null), // { notificationKey: boolean }, + lastRun: Object.create(null) // { notificationKey: number }, + } } if (window.matchMedia) { @@ -135,6 +138,14 @@ sbp('sbp/selectors/register', { // $FlowFixMe[incompatible-call] Vue.set(state, 'reverseNamespaceLookups', Object.fromEntries(Object.entries(state.namespaceLookups).map(([k, v]: [string, string]) => [v, k]))) } + if (state.periodicNotificationAlreadyFiredMap) { + if (!state.periodicNotificationAlreadyFiredMap.alreadyFired) { + state.periodicNotificationAlreadyFiredMap.alreadyFired = Object.create(null) + } + if (!state.periodicNotificationAlreadyFiredMap.lastRun) { + state.periodicNotificationAlreadyFiredMap.lastRun = Object.create(null) + } + } contractUpdate(state, (state: Object, contractIDHints: ?string[]) => { // Upgrade from version 1.0.7 to a newer version // The new group contract introduces a breaking change: the @@ -303,8 +314,11 @@ const store: any = new Vuex.Store({ getters: { ...getters, // this getter gets recomputed automatically according to the setInterval on reactiveDate + currentPaymentPeriodForGroup (state, getters) { + return (state) => getters.periodStampGivenDateForGroup(state, reactiveDate.date) + }, currentPaymentPeriod (state, getters) { - return getters.periodStampGivenDate(reactiveDate.date) + return getters.currentPaymentPeriodForGroup(getters.currentGroupState) } }, modules: { diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index a3f5217fa1..77b5520d70 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -5,7 +5,7 @@ import { debounce, has } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' import '~/shared/domains/chelonia/chelonia.js' import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' -import { NOTIFICATION_TYPE, REQUEST_TYPE } from '../shared/pubsub.js' +import { NOTIFICATION_TYPE, PUBSUB_ERROR, REQUEST_TYPE } from '../shared/pubsub.js' import { groupContractsByType, syncContractsInOrder } from './controller/actions/utils.js' import { PUBSUB_INSTANCE } from './controller/instance-keys.js' import manifests from './model/contracts/manifests.json' @@ -306,8 +306,7 @@ const setupChelonia = async (): Promise<*> => { // actual invocations actually happen (unless the last invocation resolved // and rejected) export default ((() => { - let promise - return () => { + const singletonFn = () => { if (!promise) { promise = setupChelonia().catch((e) => { console.error('[setupChelonia] Error during chelonia setup', e) @@ -317,4 +316,18 @@ export default ((() => { } return promise } + let promise + + // Listen for `PUBSUB_ERROR` events. These cause the WS to be destroyed + // When this happens, if `setupChelonia` has been called, we will reset + // `promise` and then call `singletonFn` after a short delay. + sbp('okTurtles.events/on', PUBSUB_ERROR, () => { + if (!promise) return + promise = undefined + setTimeout(() => singletonFn().catch((e) => { + console.error('[PUBSUB_ERROR handler] Error setting up Chelonia', e) + }), 100) + }) + + return singletonFn })(): () => Promise) diff --git a/frontend/views/pages/Join.vue b/frontend/views/pages/Join.vue index c06ef9098e..325993422d 100644 --- a/frontend/views/pages/Join.vue +++ b/frontend/views/pages/Join.vue @@ -49,7 +49,7 @@ div