Skip to content

Commit

Permalink
Basic notification support (#2459)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
corrideat and taoeffect authored Jan 15, 2025
1 parent 0486f23 commit 307baf6
Show file tree
Hide file tree
Showing 24 changed files with 1,297 additions and 531 deletions.
19 changes: 18 additions & 1 deletion backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
180 changes: 102 additions & 78 deletions frontend/controller/actions/group.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -521,18 +530,42 @@ 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
// actions on the contract. This will have no side-effects if /remove on
// the group contract hasn't been called.
await sbp('chelonia/contract/release', retainedContracts, { ephemeral: true })
}
}).then(() => {
const map: Map<string, any> = sbp('okTurtles.data/get', JOINED_FAILED_KEY)
map?.delete(params.contractID)
}).catch(e => {
let map: Map<string, any> = 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) {
Expand Down Expand Up @@ -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<string, any> = 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]
Expand Down Expand Up @@ -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 () => {
Expand Down
10 changes: 7 additions & 3 deletions frontend/controller/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ sbp('sbp/selectors/register', {
contractID,
contractName,
subjectContractID,
keyIds
keyIds,
returnInvocation
}) => {
if (contractID === subjectContractID) {
return
Expand Down Expand Up @@ -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 })
}
Expand Down
43 changes: 43 additions & 0 deletions frontend/controller/app/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[])
30 changes: 30 additions & 0 deletions frontend/controller/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
Loading

0 comments on commit 307baf6

Please sign in to comment.