diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5daba089..b875a72d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Messages can now be relayed using QSS [#2805](https://github.com/TryQuiet/quiet/issues/2805) * Messages can be retrieved from QSS stores [#2806](https://github.com/TryQuiet/quiet/issues/2806) * Profile photos are now uploaded via IPFS [#3048](https://github.com/TryQuiet/quiet/issues/3048) +* Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) * Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057) * Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) * Use LFA-based identity in OrbitDB diff --git a/packages/backend/src/nest/app.module.ts b/packages/backend/src/nest/app.module.ts index a3ffd1fc97..519f871cff 100644 --- a/packages/backend/src/nest/app.module.ts +++ b/packages/backend/src/nest/app.module.ts @@ -23,6 +23,7 @@ import { LIBP2P_DB_PATH, QSS_ALLOWED, QSS_ENDPOINT, + QPS_ALLOWED, } from './const' import { ConfigOptions, ConnectionsManagerOptions, ConnectionsManagerTypes } from './types' import { LocalDbModule } from './local-db/local-db.module' @@ -39,6 +40,7 @@ import { Level } from 'level' import { createLogger } from './common/logger' import { SocketActionsMap, SocketEventsMap } from '@quiet/types' import { QSSModule } from './qss/qss.module' +import { QPSModule } from './qps/qps.module' import { verifyToken } from './common/token' import { OrbitDbModule } from './storage/orbitDb/orbitdb.module' import { CommonModule } from './common/common.module' @@ -60,6 +62,7 @@ const logger = createLogger('appModule') ConnectionsManagerModule, TorModule, QSSModule, + QPSModule, ], providers: [ { @@ -233,6 +236,10 @@ export class AppModule { provide: QSS_ENDPOINT, useValue: process.env.QSS_ENDPOINT, }, + { + provide: QPS_ALLOWED, + useValue: process.env.QPS_ALLOWED === 'true', + }, ], exports: [ CONFIG_OPTIONS, @@ -245,6 +252,7 @@ export class AppModule { LEVEL_DB, QSS_ALLOWED, QSS_ENDPOINT, + QPS_ALLOWED, ], } } diff --git a/packages/backend/src/nest/common/test.module.ts b/packages/backend/src/nest/common/test.module.ts index 3c79690311..503d3aecde 100644 --- a/packages/backend/src/nest/common/test.module.ts +++ b/packages/backend/src/nest/common/test.module.ts @@ -18,6 +18,7 @@ import { LIBP2P_DB_PATH, QSS_ALLOWED, QSS_ENDPOINT, + QPS_ALLOWED, } from '../const' import { ConfigOptions } from '../types' import path from 'path' @@ -124,6 +125,10 @@ export const defaultConfigForTest = { provide: QSS_ENDPOINT, useFactory: () => undefined, }, + { + provide: QPS_ALLOWED, + useFactory: () => true, + }, ], exports: [ CONFIG_OPTIONS, @@ -137,6 +142,7 @@ export const defaultConfigForTest = { LIBP2P_DB_PATH, QSS_ALLOWED, QSS_ENDPOINT, + QPS_ALLOWED, ], }) export class TestModule {} diff --git a/packages/backend/src/nest/connections-manager/connections-manager.module.ts b/packages/backend/src/nest/connections-manager/connections-manager.module.ts index aaac84fc87..deae3227b3 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.module.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.module.ts @@ -8,6 +8,7 @@ import { StorageServiceClientModule } from '../storageServiceClient/storageServi import { Libp2pModule } from '../libp2p/libp2p.module' import { SigChainModule } from '../auth/sigchain.service.module' import { QSSModule } from '../qss/qss.module' +import { QPSModule } from '../qps/qps.module' @Module({ imports: [ @@ -19,6 +20,7 @@ import { QSSModule } from '../qss/qss.module' StorageServiceClientModule, SigChainModule, QSSModule, + QPSModule, ], providers: [ConnectionsManagerService], exports: [ConnectionsManagerService], diff --git a/packages/backend/src/nest/const.ts b/packages/backend/src/nest/const.ts index 88215eb9a9..4865a7f5c1 100644 --- a/packages/backend/src/nest/const.ts +++ b/packages/backend/src/nest/const.ts @@ -68,3 +68,5 @@ export const TOR_PASSWORD_PROVIDER = 'TOR_PASSWORD_PROVIDER' export const QSS_ALLOWED = 'QSS_ALLOWED' export const QSS_ENDPOINT = 'QSS_ENDPOINT' + +export const QPS_ALLOWED = 'QPS_ALLOWED' diff --git a/packages/backend/src/nest/qps/qps.module.ts b/packages/backend/src/nest/qps/qps.module.ts new file mode 100644 index 0000000000..bac889a298 --- /dev/null +++ b/packages/backend/src/nest/qps/qps.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { SocketModule } from '../socket/socket.module' +import { QSSModule } from '../qss/qss.module' +import { SigChainModule } from '../auth/sigchain.service.module' +import { QPSService } from './qps.service' + +@Module({ + imports: [SocketModule, QSSModule, SigChainModule], + providers: [QPSService], + exports: [QPSService], +}) +export class QPSModule {} diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts new file mode 100644 index 0000000000..5804a7b2b3 --- /dev/null +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -0,0 +1,232 @@ +import { jest } from '@jest/globals' +import EventEmitter from 'events' +import { QPSService } from './qps.service' +import { CommunityOperationStatus, QSSEvents, WebsocketEvents } from '../qss/qss.types' +import { RoleName } from '../auth/services/roles/roles' +import { DateTime } from 'luxon' + +/** + * Lightweight mocks — avoid bootstrapping the full NestJS module graph. + * QSSClient and SigChainService both extend EventEmitter in production, + * so the mocks do the same. + */ + +class MockQSSClient extends EventEmitter { + connected = false + sendMessage = jest.fn() +} + +class MockSigChainService extends EventEmitter { + activeChain: any = null +} + +class MockSocketService extends EventEmitter {} + +describe('QPSService', () => { + let qpsService: QPSService + let qssClient: MockQSSClient + let sigChainService: MockSigChainService + let socketService: MockSocketService + + const TOKEN = 'fake-device-token-abc123' + + const successResponse = { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + payload: { ucan: 'test-ucan' }, + } + + /** Helper: make QSS connected + sigchain joined */ + function setReady() { + qssClient.connected = true + sigChainService.activeChain = { + team: {}, + roles: { amIMemberOfRole: (role: string) => role === RoleName.MEMBER }, + } + } + + beforeEach(() => { + jest.clearAllMocks() + qssClient = new MockQSSClient() + sigChainService = new MockSigChainService() + socketService = new MockSocketService() + + qpsService = new QPSService( + true, // qpsAllowed + socketService as any, + qssClient as any, + sigChainService as any + ) + + qssClient.sendMessage.mockResolvedValue(successResponse) + + // Wire up event listeners (simulates NestJS lifecycle) + qpsService.onModuleInit() + }) + + describe('register', () => { + it('sends immediately when ready', async () => { + setReady() + + const result = await qpsService.register(TOKEN) + + expect(result).toEqual({ ucan: 'test-ucan' }) + expect(qssClient.sendMessage).toHaveBeenCalledWith( + WebsocketEvents.REGISTER_DEVICE_TOKEN, + expect.objectContaining({ + status: CommunityOperationStatus.SENDING, + payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile' }, + }), + true + ) + }) + + it('returns null and does not send when disabled', async () => { + // Create a disabled instance + const disabled = new QPSService(false, socketService as any, qssClient as any, sigChainService as any) + setReady() + + const result = await disabled.register(TOKEN) + + expect(result).toBeNull() + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + + it('caches token when QSS is not connected', async () => { + qssClient.connected = false + sigChainService.activeChain = { + team: {}, + roles: { amIMemberOfRole: () => true }, + } + + const result = await qpsService.register(TOKEN) + + expect(result).toBeNull() + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + + it('caches token when sigchain has no member key', async () => { + qssClient.connected = true + sigChainService.activeChain = null + + const result = await qpsService.register(TOKEN) + + expect(result).toBeNull() + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + + it('overwrites cached token with latest value', async () => { + qssClient.connected = false + sigChainService.activeChain = null + + await qpsService.register('old-token') + await qpsService.register(TOKEN) + + // Now become ready and flush + setReady() + qssClient.emit(QSSEvents.QSS_CONNECTED) + + // Wait for async flush + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) + expect(qssClient.sendMessage).toHaveBeenCalledWith( + WebsocketEvents.REGISTER_DEVICE_TOKEN, + expect.objectContaining({ + payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile' }, + }), + true + ) + }) + }) + + describe('flush on QSS_CONNECTED', () => { + it('flushes cached token when QSS connects and sigchain is joined', async () => { + // Cache token while not ready + qssClient.connected = false + sigChainService.activeChain = null + await qpsService.register(TOKEN) + expect(qssClient.sendMessage).not.toHaveBeenCalled() + + // Become ready and emit connected + setReady() + qssClient.emit(QSSEvents.QSS_CONNECTED) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) + expect(qssClient.sendMessage).toHaveBeenCalledWith( + WebsocketEvents.REGISTER_DEVICE_TOKEN, + expect.objectContaining({ + payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile' }, + }), + true + ) + }) + + it('does not flush when QSS connects but sigchain is not joined', async () => { + qssClient.connected = false + sigChainService.activeChain = null + await qpsService.register(TOKEN) + + // QSS connects but sigchain still not joined + qssClient.connected = true + qssClient.emit(QSSEvents.QSS_CONNECTED) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + }) + + describe('flush on sigchain updated', () => { + it('flushes cached token when sigchain joins and QSS is connected', async () => { + // Cache token: QSS connected but no sigchain + qssClient.connected = true + sigChainService.activeChain = null + await qpsService.register(TOKEN) + expect(qssClient.sendMessage).not.toHaveBeenCalled() + + // Sigchain joins + setReady() + sigChainService.emit('updated') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) + }) + + it('does not flush when sigchain updates but QSS is not connected', async () => { + qssClient.connected = false + sigChainService.activeChain = null + await qpsService.register(TOKEN) + + // Sigchain joins but QSS still disconnected + sigChainService.activeChain = { + team: {}, + roles: { amIMemberOfRole: () => true }, + } + sigChainService.emit('updated') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + }) + + describe('flush clears cache', () => { + it('does not send twice after flushing', async () => { + qssClient.connected = false + sigChainService.activeChain = null + await qpsService.register(TOKEN) + + setReady() + qssClient.emit(QSSEvents.QSS_CONNECTED) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) + + // Second event should not trigger another send + sigChainService.emit('updated') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts new file mode 100644 index 0000000000..8ba848ec37 --- /dev/null +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common' +import { DateTime } from 'luxon' +import { createLogger } from '../common/logger' +import { QPS_ALLOWED } from '../const' +import { SocketService } from '../socket/socket.service' +import { SocketActions } from '@quiet/types' +import { QPSRegisterResponse, QPSRegisterWsResponse } from './qps.types' +import { QSSClient } from '../qss/qss.client' +import { CommunityOperationStatus, QSSEvents, WebsocketEvents } from '../qss/qss.types' +import { SigChainService } from '../auth/sigchain.service' +import { RoleName } from '../auth/services/roles/roles' + +const BUNDLE_ID = 'com.quietmobile' + +@Injectable() +export class QPSService implements OnModuleInit { + private readonly logger = createLogger('qps:service') + private _pendingDeviceToken: string | null = null + + constructor( + @Inject(QPS_ALLOWED) private readonly qpsAllowed: boolean, + private readonly socketService: SocketService, + private readonly qssClient: QSSClient, + private readonly sigChainService: SigChainService + ) {} + + public get enabled(): boolean { + return this.qpsAllowed + } + + private get ready(): boolean { + return this.qssClient.connected && this._hasMemberKey() + } + + onModuleInit() { + this.socketService.on(SocketActions.SEND_DEVICE_TOKEN, async (payload: { deviceToken: string }) => { + this.logger.info('Received device token from frontend') + await this.register(payload.deviceToken) + }) + + this.qssClient.on(QSSEvents.QSS_CONNECTED, () => this._flushPendingToken()) + this.sigChainService.on('updated', () => this._flushPendingToken()) + } + + /** + * Registers the device token with QPS + * @param deviceToken + * @returns + */ + public async register(deviceToken: string): Promise { + if (!this.enabled) { + this.logger.warn('QPS not enabled, skipping registration') + return null + } + + if (!this.ready) { + this.logger.info('QSS not connected or sigchain not joined, caching device token') + this._pendingDeviceToken = deviceToken + return null + } + + return this._register(deviceToken) + } + + private async _flushPendingToken(): Promise { + if (this._pendingDeviceToken == null || !this.ready) { + return + } + + const token = this._pendingDeviceToken + this._pendingDeviceToken = null + this.logger.info('Flushing cached device token') + await this._register(token) + } + + private _hasMemberKey(): boolean { + try { + return ( + this.sigChainService.activeChain?.team != null && + this.sigChainService.activeChain.roles.amIMemberOfRole(RoleName.MEMBER) + ) + } catch { + return false + } + } + + private async _register(deviceToken: string): Promise { + this.logger.info('Registering device token') + try { + const response = await this.qssClient.sendMessage( + WebsocketEvents.REGISTER_DEVICE_TOKEN, + { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SENDING, + payload: { deviceToken, bundleId: BUNDLE_ID }, + }, + true + ) + + if (response?.status === CommunityOperationStatus.SUCCESS && response.payload?.ucan) { + this.logger.info('QPS registration successful, received UCAN') + return { ucan: response.payload.ucan } + } + + this.logger.warn(`QPS registration failed: ${response?.reason ?? 'unknown'}`) + return null + } catch (e) { + this.logger.error('Error registering device token', e) + return null + } + } +} diff --git a/packages/backend/src/nest/qps/qps.types.ts b/packages/backend/src/nest/qps/qps.types.ts new file mode 100644 index 0000000000..dceef24164 --- /dev/null +++ b/packages/backend/src/nest/qps/qps.types.ts @@ -0,0 +1,22 @@ +import { BaseWebsocketMessage } from '../qss/qss.types' + +export interface QPSRegisterResponse { + ucan: string +} + +export interface QPSRegisterPayload { + deviceToken: string + bundleId: string +} + +export interface QPSRegisterMessage extends BaseWebsocketMessage { + payload: QPSRegisterPayload +} + +export interface QPSRegisterResponsePayload { + ucan: string +} + +export interface QPSRegisterWsResponse extends BaseWebsocketMessage { + payload?: QPSRegisterResponsePayload +} diff --git a/packages/backend/src/nest/qss/qss.module.ts b/packages/backend/src/nest/qss/qss.module.ts index 89d3523a0f..a6675f1303 100644 --- a/packages/backend/src/nest/qss/qss.module.ts +++ b/packages/backend/src/nest/qss/qss.module.ts @@ -14,6 +14,6 @@ import { CommonModule } from '../common/common.module' @Module({ imports: [SigChainModule, LocalDbModule, forwardRef(() => OrbitDbModule), CaptchaModule, SocketModule, CommonModule], providers: [QSSService, QSSClient, QSSAuthConnectionManager, QSSAuthConnection], - exports: [QSSService], + exports: [QSSService, QSSClient], }) export class QSSModule {} diff --git a/packages/backend/src/nest/qss/qss.types.ts b/packages/backend/src/nest/qss/qss.types.ts index 2df24baca5..422746df8f 100644 --- a/packages/backend/src/nest/qss/qss.types.ts +++ b/packages/backend/src/nest/qss/qss.types.ts @@ -29,6 +29,7 @@ export enum WebsocketEvents { LOG_ENTRY_PULL = 'log-entry-pull', VERIFY_CAPTCHA = 'verify-captcha', GET_CAPTCHA_SITE_KEY = 'get-captcha-site-key', + REGISTER_DEVICE_TOKEN = 'register-device-token', } /** diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index c33b9209bc..18506f70d0 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -231,6 +231,11 @@ export class SocketService extends EventEmitter implements OnModuleInit { socket.on(SocketActions.TOGGLE_P2P, async (enabled: boolean, callback: (response: boolean) => void) => { this.emit(SocketActions.TOGGLE_P2P, enabled, callback) }) + + // ====== Push Notifications ====== + socket.on(SocketActions.SEND_DEVICE_TOKEN, async (payload: { deviceToken: string }) => { + this.emit(SocketActions.SEND_DEVICE_TOKEN, payload) + }) }) // Ensure the underlying connections get closed. See: diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts index 9683c3ff86..8f8b30a459 100644 --- a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts +++ b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts @@ -8,6 +8,7 @@ import { handlePermissionResultSaga, PermissionResultPayload, } from './handlePermissionResult/handlePermissionResult.saga' +import { pushNotifications } from '@quiet/state-manager' import { createLogger } from '../../utils/logger' const logger = createLogger('pushNotificationsMasterSaga') @@ -95,7 +96,7 @@ function* watchDeviceToken(): Generator { while (true) { const { token } = yield* take(channel) logger.info('Received device token') - yield* put(pushNotificationsActions.setDeviceToken(token)) + yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token)) } } finally { if (yield cancelled()) { diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.selectors.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.selectors.ts index bb8517c4a9..204febed56 100644 --- a/packages/mobile/src/store/pushNotifications/pushNotifications.selectors.ts +++ b/packages/mobile/src/store/pushNotifications/pushNotifications.selectors.ts @@ -9,10 +9,7 @@ export const permissionStatus = createSelector(pushNotificationsSlice, state => export const permissionRequested = createSelector(pushNotificationsSlice, state => state.permissionRequested) -export const deviceToken = createSelector(pushNotificationsSlice, state => state.deviceToken) - export const pushNotificationsSelectors = { permissionStatus, permissionRequested, - deviceToken, } diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.slice.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.slice.ts index c36a328d4a..8006eaedbc 100644 --- a/packages/mobile/src/store/pushNotifications/pushNotifications.slice.ts +++ b/packages/mobile/src/store/pushNotifications/pushNotifications.slice.ts @@ -5,7 +5,6 @@ import { NotificationPermissionStatus } from './pushNotifications.types' export class PushNotificationsState { permissionStatus: NotificationPermissionStatus = NotificationPermissionStatus.NotDetermined permissionRequested: boolean = false - deviceToken: string | null = null } export const pushNotificationsSlice = createSlice({ @@ -17,9 +16,6 @@ export const pushNotificationsSlice = createSlice({ state.permissionStatus = action.payload state.permissionRequested = true }, - setDeviceToken: (state, action: PayloadAction) => { - state.deviceToken = action.payload - }, checkPermissionOnLaunch: state => state, }, }) diff --git a/packages/state-manager/src/index.ts b/packages/state-manager/src/index.ts index 23c67b3c3b..2a3c6866c5 100644 --- a/packages/state-manager/src/index.ts +++ b/packages/state-manager/src/index.ts @@ -51,6 +51,10 @@ import { networkSelectors } from './sagas/network/network.selectors' import type {} from 'pkijs' import { captchaActions, captchaReducer } from './sagas/captcha/captcha.slice' import { captchaSelectors } from './sagas/captcha/captcha.selectors' +import { + pushNotificationsActions as _pushNotificationsActions, + pushNotificationsReducer as _pushNotificationsReducer, +} from './sagas/pushNotifications/pushNotifications.slice' export { LoadingPanelType } from './sagas/network/network.types' export type { Store } from './sagas/store.types' export type { TestStore, TestStoreState } from './utils/tests/types' @@ -162,6 +166,11 @@ export const network = { selectors: networkSelectors, } +export const pushNotifications = { + reducer: _pushNotificationsReducer, + actions: _pushNotificationsActions, +} + export const socket = { useIO, } diff --git a/packages/state-manager/src/sagas/pushNotifications/pushNotifications.master.saga.ts b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.master.saga.ts new file mode 100644 index 0000000000..471e54d5d6 --- /dev/null +++ b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.master.saga.ts @@ -0,0 +1,8 @@ +import { takeEvery } from 'typed-redux-saga' +import { Socket } from '../../types' +import { pushNotificationsActions } from './pushNotifications.slice' +import { sendDeviceTokenSaga } from './sendDeviceToken.saga' + +export function* pushNotificationsMasterSaga(socket: Socket): Generator { + yield* takeEvery(pushNotificationsActions.sendDeviceTokenToBackend.type, sendDeviceTokenSaga, socket) +} diff --git a/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts new file mode 100644 index 0000000000..ad21c3fa06 --- /dev/null +++ b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts @@ -0,0 +1,15 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { StoreKeys } from '../store.keys' + +export class PushNotificationsState {} + +export const pushNotificationsSlice = createSlice({ + initialState: { ...new PushNotificationsState() }, + name: StoreKeys.PushNotifications, + reducers: { + sendDeviceTokenToBackend: (state, _action: PayloadAction) => {}, + }, +}) + +export const pushNotificationsActions = pushNotificationsSlice.actions +export const pushNotificationsReducer = pushNotificationsSlice.reducer diff --git a/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts b/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts new file mode 100644 index 0000000000..d59d176822 --- /dev/null +++ b/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts @@ -0,0 +1,14 @@ +import { apply } from 'typed-redux-saga' +import { PayloadAction } from '@reduxjs/toolkit' +import { Socket } from '../../types' +import { SocketActions } from '@quiet/types' +import { createLogger } from '../../utils/logger' +import { applyEmitParams } from '../../types' + +const logger = createLogger('sendDeviceTokenSaga') + +export function* sendDeviceTokenSaga(socket: Socket, action: PayloadAction): Generator { + const deviceToken = action.payload + logger.info('Sending device token to backend') + yield* apply(socket, socket.emit, applyEmitParams(SocketActions.SEND_DEVICE_TOKEN, { deviceToken })) +} diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 17f44a63a3..4215fdf693 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -47,6 +47,7 @@ import { createLogger } from '../../../utils/logger' import { InviteResult } from '@localfirst/auth' import { captchaActions } from '../../captcha/captcha.slice' import { captchaMasterSaga } from '../../captcha/captchaMasterSaga' +import { pushNotificationsMasterSaga } from '../../pushNotifications/pushNotifications.master.saga' const logger = createLogger('startConnectionSaga') @@ -241,6 +242,7 @@ export function* useIO(socket: Socket): Generator { fork(connectionMasterSaga, socket), fork(errorsMasterSaga), fork(captchaMasterSaga, socket), + fork(pushNotificationsMasterSaga, socket), ]) } finally { logger.info('useIO stopping') diff --git a/packages/state-manager/src/sagas/store.keys.ts b/packages/state-manager/src/sagas/store.keys.ts index 7664875c4e..d7a24471f4 100644 --- a/packages/state-manager/src/sagas/store.keys.ts +++ b/packages/state-manager/src/sagas/store.keys.ts @@ -15,6 +15,7 @@ export enum StoreKeys { Connection = 'Connection', Settings = 'Settings', Files = 'Files', + PushNotifications = 'PushNotifications', // For testing purposes LastAction = 'LastAction', CollectData = 'CollectData', diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 1704d90e7a..7659a6c395 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -629,5 +629,10 @@ export const getSocketFactory = async () => { factory.define(SocketActions.TOGGLE_P2P, Object, () => true) + // Push notification events + factory.define<{ deviceToken: string }>(SocketActions.SEND_DEVICE_TOKEN, Object, { + deviceToken: 'test-device-token', + }) + return factory } diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index d6ac8999f0..f3cead2d8e 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -93,6 +93,9 @@ export enum SocketActions { HCAPTCHA_FORM_RESPONSE = 'hcaptchaFormResponse', HCAPTCHA_REQUEST = 'hcaptchaRequest', + // ====== Push Notifications ====== + SEND_DEVICE_TOKEN = 'sendDeviceToken', + // ====== Misc ====== /** * For moving data from the frontend to the backend. Load migration @@ -199,6 +202,9 @@ export interface SocketActionsMap { [SocketActions.HCAPTCHA_FORM_RESPONSE]: EmitEvent [SocketActions.HCAPTCHA_REQUEST]: EmitEvent + // ====== Push Notifications ====== + [SocketActions.SEND_DEVICE_TOKEN]: EmitEvent<{ deviceToken: string }> + // ====== Misc ====== [SocketActions.TOGGLE_P2P]: EmitEvent void> }