Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee75d53
request permissions when ios app opens, detect changes to permission …
adrastaea Jan 28, 2026
6c2265d
register when permission is granted and rely solely on event channels…
adrastaea Jan 28, 2026
e2d68c0
simplify sagas, ensure event channels are set up first
adrastaea Jan 28, 2026
ae67d83
update changelog
adrastaea Jan 28, 2026
1a5bee6
fix mobile unit tests
adrastaea Jan 28, 2026
337ba79
pass token to backend and scaffold registration
adrastaea Jan 30, 2026
8e51214
cache token until we are part of a community and connected to qss
adrastaea Jan 30, 2026
98ca372
Merge branch 'develop' into feat/3080-apns-token-registration
adrastaea Jan 30, 2026
26b3e0e
update changelog
adrastaea Jan 30, 2026
7cac4d3
fix typo in changelog
adrastaea Jan 30, 2026
4214c0b
remove caching of device token in redux because it is not needed
adrastaea Jan 30, 2026
97c4248
adjust mocks
adrastaea Feb 4, 2026
a222a95
try to get around ci weirdness
adrastaea Feb 4, 2026
d68744d
Merge branch 'develop' into feat/3079-ios-perm-request
adrastaea Feb 4, 2026
f4020ca
fix mobile tests
adrastaea Feb 4, 2026
c818e2b
Merge branch 'develop' into feat/3080-apns-token-registration
adrastaea Feb 5, 2026
9ab7cf8
add qps consts to test module
adrastaea Feb 5, 2026
c492130
Merge remote-tracking branch 'origin/feat/3079-ios-perm-request' into…
adrastaea Feb 5, 2026
7dc988d
fix state-manager test
adrastaea Feb 5, 2026
7450e70
Merge branch 'develop' into feat/3080-apns-token-registration
adrastaea Feb 19, 2026
4ca0e49
remove http related deprecated code
adrastaea Feb 20, 2026
ec963aa
Merge branch 'develop' into feat/3080-apns-token-registration
adrastaea Feb 20, 2026
105fd21
fix merge conflict
adrastaea Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/nest/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -60,6 +62,7 @@ const logger = createLogger('appModule')
ConnectionsManagerModule,
TorModule,
QSSModule,
QPSModule,
],
providers: [
{
Expand Down Expand Up @@ -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,
Expand All @@ -245,6 +252,7 @@ export class AppModule {
LEVEL_DB,
QSS_ALLOWED,
QSS_ENDPOINT,
QPS_ALLOWED,
],
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/nest/common/test.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LIBP2P_DB_PATH,
QSS_ALLOWED,
QSS_ENDPOINT,
QPS_ALLOWED,
} from '../const'
import { ConfigOptions } from '../types'
import path from 'path'
Expand Down Expand Up @@ -124,6 +125,10 @@ export const defaultConfigForTest = {
provide: QSS_ENDPOINT,
useFactory: () => undefined,
},
{
provide: QPS_ALLOWED,
useFactory: () => true,
},
],
exports: [
CONFIG_OPTIONS,
Expand All @@ -137,6 +142,7 @@ export const defaultConfigForTest = {
LIBP2P_DB_PATH,
QSS_ALLOWED,
QSS_ENDPOINT,
QPS_ALLOWED,
],
})
export class TestModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -19,6 +20,7 @@ import { QSSModule } from '../qss/qss.module'
StorageServiceClientModule,
SigChainModule,
QSSModule,
QPSModule,
],
providers: [ConnectionsManagerService],
exports: [ConnectionsManagerService],
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/nest/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 12 additions & 0 deletions packages/backend/src/nest/qps/qps.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
232 changes: 232 additions & 0 deletions packages/backend/src/nest/qps/qps.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<any>()
}

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)
})
})
})
Loading
Loading