mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - expand phone application platform
Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPhoneApp,
|
||||
isExternalPhoneApp,
|
||||
replaceExternalPhoneApps,
|
||||
} from '@/config/apps'
|
||||
import {
|
||||
normalizeExternalPhoneApp,
|
||||
useAppCatalogStore,
|
||||
} from '@/stores/app-catalog'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureAppNotificationPreferences: vi.fn(),
|
||||
reconcileCatalog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app-store', () => ({
|
||||
useAppStoreStore: () => ({ reconcileCatalog: mocks.reconcileCatalog }),
|
||||
}))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({
|
||||
ensureAppNotificationPreferences: mocks.ensureAppNotificationPreferences,
|
||||
}),
|
||||
}))
|
||||
|
||||
function validApp(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
bundled: false,
|
||||
category: 'utilities',
|
||||
icon: 'https://cfx-nui-example/web/icon.webp',
|
||||
id: 'example-app',
|
||||
name: 'Example App',
|
||||
ownerResource: 'example_resource',
|
||||
ui: 'https://cfx-nui-example/web/index.html',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('custom app catalog', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
replaceExternalPhoneApps([])
|
||||
mocks.ensureAppNotificationPreferences.mockReset()
|
||||
mocks.reconcileCatalog.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
replaceExternalPhoneApps([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes a validated external iframe app', () => {
|
||||
const app = normalizeExternalPhoneApp(
|
||||
validApp({
|
||||
compatibility: { fixBlur: true, unsafe: () => undefined },
|
||||
defaultInstalled: true,
|
||||
iconBackground: '#1d4ed8',
|
||||
permissions: ['app.close', 'app.close', 'device.storage'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(app).toMatchObject({
|
||||
bridgeMode: 'legacy',
|
||||
capabilities: ['app.close', 'device.storage'],
|
||||
compatibility: { fixBlur: true },
|
||||
defaultInstalled: true,
|
||||
id: 'example-app',
|
||||
iconBackground: '#1d4ed8',
|
||||
kind: 'external',
|
||||
readyTimeoutMs: 8000,
|
||||
removable: true,
|
||||
route: '/apps/example-app',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts only color-shaped icon backgrounds', () => {
|
||||
expect(
|
||||
normalizeExternalPhoneApp(
|
||||
validApp({ iconBackground: 'hsl(221 83% 53%)' }),
|
||||
),
|
||||
).toMatchObject({ iconBackground: 'hsl(221 83% 53%)' })
|
||||
expect(
|
||||
normalizeExternalPhoneApp(
|
||||
validApp({
|
||||
iconBackground: 'url(https://attacker.test/tracker.png)',
|
||||
}),
|
||||
),
|
||||
).not.toHaveProperty('iconBackground')
|
||||
})
|
||||
|
||||
it('rejects unsafe URLs and built-in id collisions', () => {
|
||||
expect(
|
||||
normalizeExternalPhoneApp(validApp({ ui: 'javascript:alert(1)' })),
|
||||
).toBeNull()
|
||||
expect(normalizeExternalPhoneApp(validApp({ id: 'phone' }))).toBeNull()
|
||||
expect(
|
||||
normalizeExternalPhoneApp(validApp({ readyTimeoutMs: 31_000 })),
|
||||
).toMatchObject({ readyTimeoutMs: 8000 })
|
||||
})
|
||||
|
||||
it('replaces the runtime entries and reconciles dependent stores', () => {
|
||||
const catalog = useAppCatalogStore()
|
||||
|
||||
catalog.replaceCatalog({
|
||||
apps: [
|
||||
validApp(),
|
||||
validApp(),
|
||||
validApp({ id: 'invalid', ui: 'http://example.test' }),
|
||||
],
|
||||
})
|
||||
|
||||
const app = getPhoneApp('example-app')
|
||||
expect(isExternalPhoneApp(app)).toBe(true)
|
||||
expect(catalog.externalApps).toHaveLength(1)
|
||||
expect(mocks.ensureAppNotificationPreferences).toHaveBeenCalledWith([
|
||||
'example-app',
|
||||
])
|
||||
expect(mocks.reconcileCatalog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('queues messages only for registered external apps', () => {
|
||||
const catalog = useAppCatalogStore()
|
||||
catalog.replaceCatalog({ apps: [validApp()] })
|
||||
|
||||
expect(catalog.queueHostMessage('example-app', { hello: 'world' })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(catalog.queueHostMessage('phone', {})).toBe(false)
|
||||
expect(catalog.hostMessages['example-app']).toMatchObject([
|
||||
{ payload: { hello: 'world' }, sequence: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Grid2X2 } from 'lucide-vue-next'
|
||||
import { defineStore } from 'pinia'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import {
|
||||
BUILTIN_PHONE_APP_IDS,
|
||||
getPhoneApp,
|
||||
isExternalPhoneApp,
|
||||
isValidExternalPhoneAppId,
|
||||
PHONE_APPS,
|
||||
replaceExternalPhoneApps,
|
||||
} from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
CustomAppHostMessage,
|
||||
CustomAppOpenRequest,
|
||||
BuiltinPhoneAppId,
|
||||
ExternalPhoneAppDefinition,
|
||||
PhoneAppCategory,
|
||||
} from '@/types/apps'
|
||||
|
||||
const APP_CATEGORIES: ReadonlySet<PhoneAppCategory> = new Set([
|
||||
'games',
|
||||
'productivity',
|
||||
'shopping',
|
||||
'social',
|
||||
'utilities',
|
||||
])
|
||||
const MAX_PENDING_MESSAGES = 50
|
||||
const ICON_BACKGROUND_HEX_PATTERN =
|
||||
/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i
|
||||
const ICON_BACKGROUND_NAMED_PATTERN = /^[a-z]{3,32}$/i
|
||||
const ICON_BACKGROUND_FUNCTION_PATTERN =
|
||||
/^(?:rgb|rgba|hsl|hsla)\([\d\s.,%+\-/degraturn]+\)$/i
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function readRequiredString(
|
||||
source: Record<string, unknown>,
|
||||
key: string,
|
||||
maximumLength: number,
|
||||
): string | null {
|
||||
const value = source[key]
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
return normalized && normalized.length <= maximumLength ? normalized : null
|
||||
}
|
||||
|
||||
function readOptionalString(
|
||||
source: Record<string, unknown>,
|
||||
key: string,
|
||||
maximumLength: number,
|
||||
): string {
|
||||
const value = source[key]
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim().slice(0, maximumLength)
|
||||
}
|
||||
|
||||
function readIconBackground(value: unknown): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.length > 64) return ''
|
||||
|
||||
return ICON_BACKGROUND_HEX_PATTERN.test(normalized) ||
|
||||
ICON_BACKGROUND_NAMED_PATTERN.test(normalized) ||
|
||||
ICON_BACKGROUND_FUNCTION_PATTERN.test(normalized)
|
||||
? normalized
|
||||
: ''
|
||||
}
|
||||
|
||||
function readHttpsUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length > 2048) return null
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
|
||||
return null
|
||||
}
|
||||
return parsed.href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readCapabilities(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const capabilities: string[] = []
|
||||
for (const capability of value) {
|
||||
if (
|
||||
typeof capability === 'string' &&
|
||||
/^[a-z][a-z0-9._-]{1,63}$/.test(capability) &&
|
||||
!capabilities.includes(capability)
|
||||
) {
|
||||
capabilities.push(capability)
|
||||
}
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
function readCompatibilityValue(value: unknown, depth = 0): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
(typeof value === 'number' && Number.isFinite(value))
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') return value.slice(0, 256)
|
||||
if (depth >= 2) return undefined
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, 32)
|
||||
.map((item) => readCompatibilityValue(item, depth + 1))
|
||||
.filter((item) => item !== undefined)
|
||||
}
|
||||
|
||||
const source = readRecord(value)
|
||||
if (!source) return undefined
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(source).slice(0, 32)) {
|
||||
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(key)) continue
|
||||
const normalized = readCompatibilityValue(item, depth + 1)
|
||||
if (normalized !== undefined) result[key] = normalized
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function readCompatibility(value: unknown): Record<string, unknown> {
|
||||
const source = readRecord(value)
|
||||
if (!source) return {}
|
||||
return (readCompatibilityValue(source) as Record<string, unknown>) ?? {}
|
||||
}
|
||||
|
||||
export function normalizeExternalPhoneApp(
|
||||
value: unknown,
|
||||
fallbackOrder = 0,
|
||||
): ExternalPhoneAppDefinition | null {
|
||||
const source = readRecord(value)
|
||||
if (!source) return null
|
||||
|
||||
const id = readRequiredString(source, 'id', 64)
|
||||
const name = readRequiredString(source, 'name', 64)
|
||||
const ownerResource = readRequiredString(source, 'ownerResource', 128)
|
||||
const ui = readHttpsUrl(source.ui)
|
||||
const icon = readHttpsUrl(source.icon)
|
||||
if (
|
||||
!id ||
|
||||
!isValidExternalPhoneAppId(id) ||
|
||||
BUILTIN_PHONE_APP_IDS.has(id as BuiltinPhoneAppId) ||
|
||||
!name ||
|
||||
!ownerResource ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(ownerResource) ||
|
||||
!ui ||
|
||||
!icon
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const category = APP_CATEGORIES.has(source.category as PhoneAppCategory)
|
||||
? (source.category as PhoneAppCategory)
|
||||
: 'utilities'
|
||||
const bundled = source.bundled === true
|
||||
const gridOrder =
|
||||
typeof source.gridOrder === 'number' &&
|
||||
Number.isFinite(source.gridOrder) &&
|
||||
source.gridOrder >= 0
|
||||
? Math.floor(source.gridOrder)
|
||||
: 1000 + fallbackOrder
|
||||
const iconBackground = readIconBackground(source.iconBackground)
|
||||
|
||||
return {
|
||||
bridgeMode:
|
||||
source.bridgeMode === 'sky' || source.bridgeMode === 'legacy'
|
||||
? source.bridgeMode
|
||||
: bundled
|
||||
? 'sky'
|
||||
: 'legacy',
|
||||
bundled,
|
||||
capabilities: readCapabilities(source.capabilities ?? source.permissions),
|
||||
category,
|
||||
component: null,
|
||||
compatibility: readCompatibility(source.compatibility),
|
||||
defaultInstalled: source.defaultInstalled === true,
|
||||
description: readOptionalString(source, 'description', 320),
|
||||
developer: readOptionalString(source, 'developer', 96),
|
||||
dockOrder: null,
|
||||
gridOrder,
|
||||
icon: markRaw(Grid2X2),
|
||||
...(iconBackground ? { iconBackground } : {}),
|
||||
iconClass: 'app-icon--custom',
|
||||
iconImage: icon,
|
||||
id,
|
||||
kind: 'external',
|
||||
name,
|
||||
orientation: source.orientation === 'landscape' ? 'landscape' : 'portrait',
|
||||
ownerResource,
|
||||
readyTimeoutMs:
|
||||
typeof source.readyTimeoutMs === 'number' &&
|
||||
Number.isFinite(source.readyTimeoutMs) &&
|
||||
source.readyTimeoutMs >= 1000 &&
|
||||
source.readyTimeoutMs <= 30_000
|
||||
? Math.floor(source.readyTimeoutMs)
|
||||
: 8000,
|
||||
removable: source.removable !== false,
|
||||
route: `/apps/${id}`,
|
||||
ui,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAppCatalogStore = defineStore('app-catalog', {
|
||||
state: () => ({
|
||||
externalApps: [] as ExternalPhoneAppDefinition[],
|
||||
hostMessages: {} as Record<string, CustomAppHostMessage[]>,
|
||||
nextSequence: 1,
|
||||
openRequests: {} as Record<string, CustomAppOpenRequest>,
|
||||
}),
|
||||
getters: {
|
||||
apps: () => PHONE_APPS,
|
||||
},
|
||||
actions: {
|
||||
replaceCatalog(payload: unknown): void {
|
||||
const source = readRecord(payload)
|
||||
if (!source || !Array.isArray(source.apps)) {
|
||||
console.error('[Custom apps] Ignored an invalid catalog payload.')
|
||||
return
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const apps: ExternalPhoneAppDefinition[] = []
|
||||
for (const [index, value] of source.apps.entries()) {
|
||||
const app = normalizeExternalPhoneApp(value, index)
|
||||
if (!app) {
|
||||
console.error(
|
||||
`[Custom apps] Ignored invalid catalog entry at index ${index}.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (seenIds.has(app.id)) {
|
||||
console.error(`[Custom apps] Ignored duplicate app id: ${app.id}`)
|
||||
continue
|
||||
}
|
||||
seenIds.add(app.id)
|
||||
apps.push(app)
|
||||
}
|
||||
|
||||
this.externalApps = apps
|
||||
replaceExternalPhoneApps(apps)
|
||||
|
||||
for (const appId of Object.keys(this.hostMessages)) {
|
||||
if (!seenIds.has(appId)) delete this.hostMessages[appId]
|
||||
}
|
||||
for (const appId of Object.keys(this.openRequests)) {
|
||||
if (!seenIds.has(appId)) delete this.openRequests[appId]
|
||||
}
|
||||
|
||||
usePhoneStore().ensureAppNotificationPreferences(
|
||||
apps.map((app) => app.id),
|
||||
)
|
||||
useAppStoreStore().reconcileCatalog()
|
||||
},
|
||||
queueHostMessage(appId: string, payload: unknown): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
if (!isExternalPhoneApp(app)) {
|
||||
console.error(
|
||||
`[Custom apps] Message target is not registered: ${appId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const messages = this.hostMessages[appId] ?? []
|
||||
messages.push({ payload, sequence: this.nextSequence })
|
||||
this.nextSequence += 1
|
||||
if (messages.length > MAX_PENDING_MESSAGES) {
|
||||
console.error(
|
||||
`[Custom apps] Pending message limit reached for ${appId}; dropped the oldest message.`,
|
||||
)
|
||||
}
|
||||
this.hostMessages[appId] = messages.slice(-MAX_PENDING_MESSAGES)
|
||||
return true
|
||||
},
|
||||
consumeHostMessages(appId: string, throughSequence: number): void {
|
||||
const remaining = (this.hostMessages[appId] ?? []).filter(
|
||||
(message) => message.sequence > throughSequence,
|
||||
)
|
||||
if (remaining.length) this.hostMessages[appId] = remaining
|
||||
else delete this.hostMessages[appId]
|
||||
},
|
||||
requestOpen(appId: string, data?: unknown): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
if (!isExternalPhoneApp(app)) {
|
||||
console.error(`[Custom apps] Open target is not registered: ${appId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
this.openRequests[appId] = {
|
||||
...(data === undefined ? {} : { data }),
|
||||
sequence: this.nextSequence,
|
||||
}
|
||||
this.nextSequence += 1
|
||||
return true
|
||||
},
|
||||
consumeOpenRequest(appId: string, sequence: number): void {
|
||||
if (this.openRequests[appId]?.sequence === sequence) {
|
||||
delete this.openRequests[appId]
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,17 +5,23 @@ import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { removeHomeApp } from '@/utils/homeLayout'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
phone: {
|
||||
device: { imei: 'phone-a' },
|
||||
isOpen: true,
|
||||
saveDeviceNamespace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({
|
||||
saveDeviceNamespace: mocks.saveDeviceNamespace,
|
||||
}),
|
||||
usePhoneStore: () => mocks.phone,
|
||||
}))
|
||||
|
||||
describe('app store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mocks.saveDeviceNamespace.mockReset()
|
||||
mocks.phone.device.imei = 'phone-a'
|
||||
mocks.phone.isOpen = true
|
||||
mocks.phone.saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,7 +38,7 @@ describe('app store', () => {
|
||||
apps.recordLaunch('mail')
|
||||
|
||||
expect(apps.launchCounts).toEqual({ mail: 4 })
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: { mail: 4 },
|
||||
@@ -54,7 +60,7 @@ describe('app store', () => {
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(apps.installingApps.snake).toBeUndefined()
|
||||
expect(apps.claimedApps).toEqual(['snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
@@ -71,7 +77,7 @@ describe('app store', () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.claimedApps).toEqual(['memory', 'snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reinstalls core and claimed apps removed from the Home Screen', () => {
|
||||
@@ -81,7 +87,7 @@ describe('app store', () => {
|
||||
apps.hydrate({ claimedApps: ['memory'] })
|
||||
apps.removeHomeApp('notes')
|
||||
apps.removeHomeApp('memory')
|
||||
mocks.saveDeviceNamespace.mockClear()
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
apps.installApp('notes')
|
||||
apps.installApp('memory')
|
||||
@@ -96,13 +102,13 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.grid).toContain('notes')
|
||||
expect(apps.homeLayout.grid).toContain('memory')
|
||||
expect(apps.claimedApps).toEqual(['memory'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('prevents protected apps from being removed from the Home Screen', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.saveDeviceNamespace.mockClear()
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
|
||||
'app-store',
|
||||
@@ -118,7 +124,7 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.hidden).not.toContain(appId)
|
||||
}
|
||||
|
||||
expect(mocks.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores protected apps hidden by older persisted layouts', () => {
|
||||
@@ -129,7 +135,7 @@ describe('app store', () => {
|
||||
|
||||
expect(apps.homeLayout.hidden).not.toContain('mail')
|
||||
expect(apps.homeLayout.grid).toContain('mail')
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
@@ -147,10 +153,54 @@ describe('app store', () => {
|
||||
apps.removeHomeApp('notes')
|
||||
expect(apps.homeLayout.grid).not.toContain('notes')
|
||||
expect(apps.homeLayout.hidden).toContain('notes')
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.installApp('snake')
|
||||
mocks.phone.device.imei = 'phone-b'
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.installingApps).toEqual({})
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels installation timers when hydration changes device scope', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.installApp('snake')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
mocks.phone.device.imei = 'phone-b'
|
||||
apps.hydrate(null)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels installation timers when the phone closes', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.installApp('snake')
|
||||
mocks.phone.isOpen = false
|
||||
apps.cancelPendingInstalls()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(apps.installingApps).toEqual({})
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
getPhoneApp,
|
||||
isExternalPhoneApp,
|
||||
isPhoneAppId,
|
||||
isPhoneAppRemovable,
|
||||
isValidExternalPhoneAppId,
|
||||
NON_REMOVABLE_PHONE_APP_IDS,
|
||||
PHONE_APPS,
|
||||
} from '@/config/apps'
|
||||
@@ -17,26 +21,55 @@ import {
|
||||
restoreHomeApp,
|
||||
type HomeArea,
|
||||
} from '@/utils/homeLayout'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
const DEFAULT_GRID_IDS = [...PHONE_APPS]
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
const DEFAULT_DOCK_IDS = PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
const CORE_APP_IDS = PHONE_APPS.filter((app) => app.category !== 'games').map(
|
||||
(app) => app.id,
|
||||
)
|
||||
|
||||
type PendingInstallation = {
|
||||
deviceImei: string
|
||||
timer: ReturnType<typeof globalThis.setTimeout>
|
||||
token: symbol
|
||||
}
|
||||
|
||||
const pendingInstallations = new WeakMap<
|
||||
object,
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return [...PHONE_APPS]
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) =>
|
||||
isExternalPhoneApp(app) ? app.defaultInstalled : app.category !== 'games',
|
||||
).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
: NON_REMOVABLE_PHONE_APP_IDS.has(appId)
|
||||
}
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
CORE_APP_IDS,
|
||||
DEFAULT_GRID_IDS,
|
||||
DEFAULT_DOCK_IDS,
|
||||
getDefaultInstalledIds(),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
),
|
||||
hydrated: false,
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
@@ -62,9 +95,18 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
cancelPendingInstalls(): void {
|
||||
const installations = pendingInstallations.get(this)
|
||||
if (installations) {
|
||||
for (const installation of installations.values()) {
|
||||
globalThis.clearTimeout(installation.timer)
|
||||
}
|
||||
pendingInstallations.delete(this)
|
||||
}
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
const installed =
|
||||
CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
(installed && !this.homeLayout.hidden.includes(id))
|
||||
@@ -72,51 +114,111 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const deviceImei = phone.device?.imei
|
||||
if (!phone.isOpen || !deviceImei) {
|
||||
console.error(
|
||||
`[App store] Installation cancelled because no phone device is open for ${id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const app = getPhoneApp(id)
|
||||
const reportInstall = !installed && isExternalPhoneApp(app)
|
||||
const token = Symbol(id)
|
||||
const installations =
|
||||
pendingInstallations.get(this) ??
|
||||
new Map<LaunchablePhoneAppId, PendingInstallation>()
|
||||
this.installingApps[id] = true
|
||||
globalThis.setTimeout(() => {
|
||||
if (CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)) {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
const pending = installations.get(id)
|
||||
if (!pending || pending.token !== token) return
|
||||
installations.delete(id)
|
||||
if (!installations.size) pendingInstallations.delete(this)
|
||||
|
||||
const activePhone = usePhoneStore()
|
||||
if (
|
||||
!activePhone.isOpen ||
|
||||
activePhone.device?.imei !== pending.deviceImei
|
||||
) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
`[App store] Installation cancelled because the active phone changed for ${id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
`[Custom apps] Installation cancelled because ${id} is no longer registered.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (this.isInstalled(id)) {
|
||||
this.restoreHomeApp(id)
|
||||
} else {
|
||||
this.claimApp(id)
|
||||
}
|
||||
delete this.installingApps[id]
|
||||
if (reportInstall) {
|
||||
void nuiCall('custom-app:lifecycle', {
|
||||
appId: id,
|
||||
event: 'install',
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
console.error(
|
||||
`[Custom apps] Install lifecycle failed for ${id}: ${response.error ?? 'request_failed'}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, INSTALL_DURATION_MS)
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
this.cancelPendingInstalls()
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
launchCounts?: unknown
|
||||
} | null
|
||||
const layoutVersion =
|
||||
data?.homeLayout && typeof data.homeLayout === 'object'
|
||||
? (data.homeLayout as { version?: unknown }).version
|
||||
: undefined
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
typeof id === 'string' &&
|
||||
(isPhoneAppId(id) ||
|
||||
(layoutVersion === 3 && isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
: []
|
||||
const installedIds = [...CORE_APP_IDS, ...this.claimedApps]
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
]
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
DEFAULT_GRID_IDS,
|
||||
DEFAULT_DOCK_IDS,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
this.homeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((id) =>
|
||||
NON_REMOVABLE_PHONE_APP_IDS.has(id),
|
||||
)
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
this.installingApps = {}
|
||||
this.launchCounts = {}
|
||||
if (data?.launchCounts && typeof data.launchCounts === 'object') {
|
||||
for (const [appId, count] of Object.entries(data.launchCounts)) {
|
||||
if (
|
||||
isPhoneAppId(appId) &&
|
||||
(isPhoneAppId(appId) ||
|
||||
(layoutVersion === 3 && isValidExternalPhoneAppId(appId))) &&
|
||||
typeof count === 'number' &&
|
||||
Number.isFinite(count) &&
|
||||
count > 0
|
||||
@@ -125,8 +227,39 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hydrated = true
|
||||
if (protectedHiddenAppIds.length) this.persist()
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: app.category !== 'games'
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
]
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = parseHomeLayout(this.homeLayout, defaults, installedIds)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hydrated && previous !== JSON.stringify(this.homeLayout)) {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
@@ -147,7 +280,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (NON_REMOVABLE_PHONE_APP_IDS.has(appId)) return
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCompaniesStore } from '@/stores/companies'
|
||||
import type {
|
||||
Company,
|
||||
CompanyDirectoryFilters,
|
||||
CompanyRequest,
|
||||
CompanySummary,
|
||||
} from '@/types/companies'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
const summary: CompanySummary = {
|
||||
acceptsRequests: true,
|
||||
announcement: null,
|
||||
availability: 'available',
|
||||
availabilityUpdatedAt: '2026-08-10T10:00:00.000Z',
|
||||
canCall: true,
|
||||
canMessage: false,
|
||||
categoryId: 'public',
|
||||
categoryName: 'Public services',
|
||||
description: 'City emergency service.',
|
||||
id: 'police',
|
||||
location: {
|
||||
address: 'Mission Row',
|
||||
coords: { x: 441, y: -981, z: 30 },
|
||||
district: 'Mission Row',
|
||||
label: 'Mission Row Station',
|
||||
},
|
||||
logoUrl: null,
|
||||
name: 'Los Santos Police',
|
||||
phoneNumber: '911',
|
||||
serviceSummary: 'Emergency response',
|
||||
verified: true,
|
||||
}
|
||||
|
||||
const company: Company = {
|
||||
...summary,
|
||||
coverUrl: null,
|
||||
hours: [],
|
||||
revision: 3,
|
||||
services: [],
|
||||
}
|
||||
|
||||
const request: CompanyRequest = {
|
||||
actions: {
|
||||
allowedStatuses: ['in_progress'],
|
||||
canAssign: false,
|
||||
canCall: true,
|
||||
canCancel: true,
|
||||
canClaim: false,
|
||||
canReply: true,
|
||||
},
|
||||
assignedLabel: null,
|
||||
companyId: company.id,
|
||||
companyLogoUrl: null,
|
||||
companyName: company.name,
|
||||
createdAt: '2026-08-10T10:00:00.000Z',
|
||||
description: 'I need assistance.',
|
||||
events: [],
|
||||
id: 'request-1',
|
||||
media: [],
|
||||
messages: [],
|
||||
phoneNumber: '911',
|
||||
revision: 1,
|
||||
serviceId: 'response',
|
||||
serviceName: 'Emergency response',
|
||||
status: 'new',
|
||||
subject: 'Help needed',
|
||||
unreadCount: 1,
|
||||
updatedAt: '2026-08-10T10:00:00.000Z',
|
||||
}
|
||||
|
||||
const filters: CompanyDirectoryFilters = {
|
||||
acceptsRequests: false,
|
||||
availability: null,
|
||||
categoryId: null,
|
||||
hasLocation: false,
|
||||
search: '',
|
||||
sort: 'relevance',
|
||||
}
|
||||
|
||||
describe('companies store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads, filters and deduplicates cursor pages', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
categories: [{ id: 'public', name: 'Public services' }],
|
||||
companies: [summary],
|
||||
nextCursor: 'page-2',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
categories: [],
|
||||
companies: [
|
||||
{ ...summary, description: 'Updated description' },
|
||||
{ ...summary, id: 'medical', name: 'Los Santos Medical' },
|
||||
],
|
||||
nextCursor: null,
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
expect(await store.loadCompanies(filters)).toBe(true)
|
||||
expect(await store.loadCompanies(filters, true)).toBe(true)
|
||||
|
||||
expect(store.directory.map((item) => item.id)).toEqual([
|
||||
'police',
|
||||
'medical',
|
||||
])
|
||||
expect(store.directory[0].description).toBe('Updated description')
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'companies:list', {
|
||||
acceptsRequests: false,
|
||||
availability: null,
|
||||
categoryId: null,
|
||||
cursor: 'page-2',
|
||||
hasLocation: false,
|
||||
search: '',
|
||||
sort: 'relevance',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses server-provided unread counts for the app badge', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
nextCursor: null,
|
||||
requests: [request],
|
||||
unreadCount: 4,
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
await store.loadMyRequests('open')
|
||||
store.applyUnreadCounts({ work: 2 })
|
||||
|
||||
expect(store.customerUnreadCount).toBe(4)
|
||||
expect(store.workUnreadCount).toBe(2)
|
||||
expect(store.unreadCount).toBe(6)
|
||||
})
|
||||
|
||||
it('does not refresh directory data before the app has loaded it', async () => {
|
||||
const store = useCompaniesStore()
|
||||
|
||||
await store.applyChanged({ area: 'directory', companyId: 'police' })
|
||||
|
||||
expect(mockNuiCall).not.toHaveBeenCalled()
|
||||
|
||||
mockNuiCall.mockResolvedValue({
|
||||
data: { categories: [], companies: [summary], nextCursor: null },
|
||||
success: true,
|
||||
})
|
||||
await store.loadCompanies(filters)
|
||||
mockNuiCall.mockClear()
|
||||
|
||||
await store.applyChanged({ area: 'directory', companyId: 'police' })
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears customer badge state when the active device has no usable SIM', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ error: 'no_sim', success: false })
|
||||
const store = useCompaniesStore()
|
||||
store.customerUnreadCount = 4
|
||||
|
||||
expect(await store.loadMyRequests('open')).toBe(false)
|
||||
expect(store.customerUnreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('drops SIM-scoped request data when the active device changes', () => {
|
||||
const store = useCompaniesStore()
|
||||
store.bindDeviceScope('device-a', 'sim-a')
|
||||
store.customerUnreadCount = 3
|
||||
store.myRequests = [request]
|
||||
store.request = request
|
||||
|
||||
store.bindDeviceScope('device-a', 'sim-b')
|
||||
|
||||
expect(store.customerUnreadCount).toBe(0)
|
||||
expect(store.myRequests).toEqual([])
|
||||
expect(store.request).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps server-resolved request media in the loaded thread', async () => {
|
||||
const requestWithMedia = {
|
||||
...request,
|
||||
media: [{ id: 12, url: 'https://example.test/request-photo.jpg' }],
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { request: requestWithMedia },
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
expect(await store.loadRequest(request.id)).toBe(true)
|
||||
expect(store.request?.media).toEqual(requestWithMedia.media)
|
||||
})
|
||||
|
||||
it('refreshes counts without replacing the selected request list', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { nextCursor: null, requests: [], unreadCount: 3 },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
context: {
|
||||
authorized: false,
|
||||
callAvailable: false,
|
||||
company: null,
|
||||
metrics: { assigned: 0, completedToday: 0, new: 0, waiting: 0 },
|
||||
ownRequests: [],
|
||||
permissions: {
|
||||
canAssign: false,
|
||||
canManageAnnouncement: false,
|
||||
canManageHours: false,
|
||||
canManageProfile: false,
|
||||
canManageServices: false,
|
||||
canSetAvailability: false,
|
||||
canTakeCalls: false,
|
||||
},
|
||||
recentRequests: [],
|
||||
role: null,
|
||||
unreadCount: 0,
|
||||
},
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
store.myRequestsList = 'closed'
|
||||
|
||||
await store.refreshUnreadCounts()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('companies:my-requests', {
|
||||
cursor: null,
|
||||
list: 'closed',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only server-resolved request identifiers when claiming', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { request: { ...request, revision: 2, status: 'assigned' } },
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
await store.claimRequest(request.id, request.revision)
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('companies:claim-request', {
|
||||
requestId: request.id,
|
||||
revision: request.revision,
|
||||
})
|
||||
expect(store.request?.status).toBe('assigned')
|
||||
})
|
||||
|
||||
it('does not apply manager edits until the server confirms them', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'revision_conflict',
|
||||
success: false,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
store.company = company
|
||||
|
||||
await store.updateProfile({
|
||||
acceptsRequests: false,
|
||||
address: 'New address',
|
||||
description: 'Changed locally',
|
||||
district: 'Downtown',
|
||||
locationLabel: 'Office',
|
||||
revision: company.revision,
|
||||
})
|
||||
|
||||
expect(store.company).toEqual(company)
|
||||
expect(store.mutationError).toBe('revision_conflict')
|
||||
})
|
||||
|
||||
it('includes the current revision when availability changes', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { company: { ...company, availability: 'busy', revision: 4 } },
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
await store.updateAvailability('busy', 3)
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('companies:update-availability', {
|
||||
availability: 'busy',
|
||||
revision: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates requests without client-owned identity fields', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { request },
|
||||
success: true,
|
||||
})
|
||||
const store = useCompaniesStore()
|
||||
|
||||
await store.createRequest({
|
||||
companyId: 'police',
|
||||
description: 'I need assistance.',
|
||||
mediaIds: ['10'],
|
||||
serviceId: 'response',
|
||||
subject: 'Help needed',
|
||||
})
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('companies:create-request', {
|
||||
companyId: 'police',
|
||||
description: 'I need assistance.',
|
||||
mediaIds: ['10'],
|
||||
serviceId: 'response',
|
||||
subject: 'Help needed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,539 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
Company,
|
||||
CompanyAvailability,
|
||||
CompanyChangedPayload,
|
||||
CompanyDirectoryFilters,
|
||||
CompanyDirectoryPage,
|
||||
CompanyHours,
|
||||
CompanyMember,
|
||||
CompanyMembersResult,
|
||||
CompanyMutationResult,
|
||||
CompanyRequest,
|
||||
CompanyRequestList,
|
||||
CompanyRequestMutationResult,
|
||||
CompanyRequestPage,
|
||||
CompanyRequestStatus,
|
||||
CompanyRequestSummary,
|
||||
CompanyService,
|
||||
CompanySummary,
|
||||
CompanyUnreadCounts,
|
||||
CompanyWorkContext,
|
||||
CompanyWorkFilter,
|
||||
CompanyWorkQueuePage,
|
||||
CreateCompanyRequest,
|
||||
PublishCompanyAnnouncement,
|
||||
UpdateCompanyProfile,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
function mergeById<T extends { id: string }>(current: T[], incoming: T[]): T[] {
|
||||
const merged = new Map(current.map((item) => [item.id, item]))
|
||||
for (const item of incoming) merged.set(item.id, item)
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export const useCompaniesStore = defineStore('companies', {
|
||||
state: () => ({
|
||||
categories: [] as CompanyDirectoryPage['categories'],
|
||||
company: null as Company | null,
|
||||
customerUnreadCount: 0,
|
||||
directory: [] as CompanySummary[],
|
||||
directoryFilters: {
|
||||
acceptsRequests: false,
|
||||
availability: null,
|
||||
categoryId: null,
|
||||
hasLocation: false,
|
||||
search: '',
|
||||
sort: 'relevance',
|
||||
} as CompanyDirectoryFilters,
|
||||
directoryError: '',
|
||||
directoryLoaded: false,
|
||||
directoryLoading: false,
|
||||
directoryLoadingMore: false,
|
||||
directoryNextCursor: null as string | null,
|
||||
deviceScopeKey: '',
|
||||
deviceScopeVersion: 0,
|
||||
members: [] as CompanyMember[],
|
||||
membersLoading: false,
|
||||
mutationError: '',
|
||||
mutating: false,
|
||||
myRequests: [] as CompanyRequestSummary[],
|
||||
myRequestsList: 'open' as CompanyRequestList,
|
||||
myRequestsError: '',
|
||||
myRequestsLoaded: false,
|
||||
myRequestsLoading: false,
|
||||
myRequestsLoadingMore: false,
|
||||
myRequestsNextCursor: null as string | null,
|
||||
request: null as CompanyRequest | null,
|
||||
requestError: '',
|
||||
requestLoading: false,
|
||||
workContext: null as CompanyWorkContext | null,
|
||||
workContextError: '',
|
||||
workContextLoaded: false,
|
||||
workContextLoading: false,
|
||||
workQueue: [] as CompanyRequestSummary[],
|
||||
workQueueFilter: 'new' as CompanyWorkFilter,
|
||||
workQueueError: '',
|
||||
workQueueLoading: false,
|
||||
workQueueLoadingMore: false,
|
||||
workQueueNextCursor: null as string | null,
|
||||
workUnreadCount: 0,
|
||||
}),
|
||||
getters: {
|
||||
unreadCount: (state): number =>
|
||||
state.customerUnreadCount + state.workUnreadCount,
|
||||
},
|
||||
actions: {
|
||||
bindDeviceScope(imei: string, simId: string | null): void {
|
||||
const key = `${imei}:${simId ?? 'no-sim'}`
|
||||
if (!this.deviceScopeKey) {
|
||||
this.deviceScopeKey = key
|
||||
return
|
||||
}
|
||||
if (this.deviceScopeKey === key) return
|
||||
this.deviceScopeKey = key
|
||||
this.resetDeviceScope()
|
||||
},
|
||||
applyUnreadCounts(counts: CompanyUnreadCounts): void {
|
||||
if (typeof counts.customer === 'number') {
|
||||
this.customerUnreadCount = Math.max(0, Math.floor(counts.customer))
|
||||
}
|
||||
if (typeof counts.work === 'number') {
|
||||
this.workUnreadCount = Math.max(0, Math.floor(counts.work))
|
||||
}
|
||||
},
|
||||
async refreshUnreadCounts(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.loadMyRequests(this.myRequestsList),
|
||||
this.loadWorkContext(),
|
||||
])
|
||||
},
|
||||
async applyChanged(change: CompanyChangedPayload): Promise<void> {
|
||||
const refreshDirectory =
|
||||
this.directoryLoaded &&
|
||||
(change.area === 'all' || change.area === 'directory')
|
||||
const refreshCustomer =
|
||||
this.myRequestsLoaded &&
|
||||
(change.area === 'all' || change.area === 'customer')
|
||||
const refreshWork =
|
||||
this.workContextLoaded &&
|
||||
(change.area === 'all' || change.area === 'work')
|
||||
const tasks: Promise<unknown>[] = []
|
||||
if (refreshDirectory) {
|
||||
tasks.push(this.loadCompanies(this.directoryFilters))
|
||||
if (change.companyId && this.company?.id === change.companyId) {
|
||||
tasks.push(this.loadCompany(change.companyId))
|
||||
}
|
||||
}
|
||||
if (refreshCustomer) tasks.push(this.loadMyRequests(this.myRequestsList))
|
||||
if (refreshWork) {
|
||||
tasks.push(
|
||||
this.loadWorkContext().then((loaded) =>
|
||||
loaded && this.workContext?.authorized
|
||||
? this.loadWorkQueue(this.workQueueFilter)
|
||||
: false,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (
|
||||
change.requestId &&
|
||||
this.request?.id === change.requestId &&
|
||||
(refreshCustomer || refreshWork)
|
||||
) {
|
||||
tasks.push(this.loadRequest(change.requestId))
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
},
|
||||
async loadCompanies(
|
||||
filters: CompanyDirectoryFilters,
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
if (append && (!this.directoryNextCursor || this.directoryLoadingMore)) {
|
||||
return false
|
||||
}
|
||||
if (append) this.directoryLoadingMore = true
|
||||
else this.directoryLoading = true
|
||||
this.directoryLoaded = true
|
||||
this.directoryFilters = { ...filters }
|
||||
const response = await nuiCall<CompanyDirectoryPage>('companies:list', {
|
||||
acceptsRequests: filters.acceptsRequests,
|
||||
availability: filters.availability,
|
||||
categoryId: filters.categoryId,
|
||||
cursor: append ? this.directoryNextCursor : null,
|
||||
hasLocation: filters.hasLocation,
|
||||
search: filters.search,
|
||||
sort: filters.sort,
|
||||
})
|
||||
this.directoryLoading = false
|
||||
this.directoryLoadingMore = false
|
||||
if (!response.success || !response.data) {
|
||||
this.directoryError = response.error ?? 'request_failed'
|
||||
if (!append) {
|
||||
this.directory = []
|
||||
this.directoryNextCursor = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.categories = response.data.categories ?? this.categories
|
||||
this.directory = append
|
||||
? mergeById(this.directory, response.data.companies)
|
||||
: response.data.companies
|
||||
this.directoryNextCursor = response.data.nextCursor
|
||||
this.directoryError = ''
|
||||
return true
|
||||
},
|
||||
async loadCompany(companyId: string): Promise<boolean> {
|
||||
this.directoryError = ''
|
||||
this.directoryLoading = true
|
||||
const response = await nuiCall<{ company: Company }>('companies:get', {
|
||||
companyId,
|
||||
})
|
||||
this.directoryLoading = false
|
||||
if (!response.success || !response.data?.company) {
|
||||
this.directoryError = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.company = response.data.company
|
||||
this.replaceCompanySummary(response.data.company)
|
||||
return true
|
||||
},
|
||||
async loadMyRequests(
|
||||
list: CompanyRequestList,
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
append &&
|
||||
(!this.myRequestsNextCursor || this.myRequestsLoadingMore)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (append) this.myRequestsLoadingMore = true
|
||||
else this.myRequestsLoading = true
|
||||
this.myRequestsLoaded = true
|
||||
this.myRequestsList = list
|
||||
const deviceScopeVersion = this.deviceScopeVersion
|
||||
const response = await nuiCall<CompanyRequestPage>(
|
||||
'companies:my-requests',
|
||||
{
|
||||
cursor: append ? this.myRequestsNextCursor : null,
|
||||
list,
|
||||
},
|
||||
)
|
||||
this.myRequestsLoading = false
|
||||
this.myRequestsLoadingMore = false
|
||||
if (deviceScopeVersion !== this.deviceScopeVersion) return false
|
||||
if (!response.success || !response.data) {
|
||||
this.myRequestsError = response.error ?? 'request_failed'
|
||||
if (!append) {
|
||||
this.myRequests = []
|
||||
this.myRequestsNextCursor = null
|
||||
if (
|
||||
response.error === 'anonymous_sim' ||
|
||||
response.error === 'device_not_found' ||
|
||||
response.error === 'no_sim'
|
||||
) {
|
||||
this.customerUnreadCount = 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.myRequests = append
|
||||
? mergeById(this.myRequests, response.data.requests)
|
||||
: response.data.requests
|
||||
this.myRequestsNextCursor = response.data.nextCursor
|
||||
this.customerUnreadCount = Math.max(0, response.data.unreadCount)
|
||||
this.myRequestsError = ''
|
||||
return true
|
||||
},
|
||||
async loadRequest(requestId: string): Promise<boolean> {
|
||||
this.requestLoading = true
|
||||
const deviceScopeVersion = this.deviceScopeVersion
|
||||
const response = await nuiCall<{ request: CompanyRequest }>(
|
||||
'companies:get-request',
|
||||
{ requestId },
|
||||
)
|
||||
this.requestLoading = false
|
||||
if (deviceScopeVersion !== this.deviceScopeVersion) return false
|
||||
if (!response.success || !response.data?.request) {
|
||||
this.requestError = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.request = response.data.request
|
||||
this.requestError = ''
|
||||
this.replaceRequestSummary(response.data.request)
|
||||
return true
|
||||
},
|
||||
async loadWorkContext(): Promise<boolean> {
|
||||
this.workContextLoaded = true
|
||||
this.workContextLoading = true
|
||||
const response = await nuiCall<{ context: CompanyWorkContext }>(
|
||||
'companies:work-context',
|
||||
)
|
||||
this.workContextLoading = false
|
||||
if (!response.success || !response.data?.context) {
|
||||
this.workContextError = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.workContext = response.data.context
|
||||
this.workUnreadCount = Math.max(0, response.data.context.unreadCount)
|
||||
this.workContextError = ''
|
||||
return true
|
||||
},
|
||||
async loadWorkQueue(
|
||||
filter: CompanyWorkFilter,
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
if (append && (!this.workQueueNextCursor || this.workQueueLoadingMore)) {
|
||||
return false
|
||||
}
|
||||
if (append) this.workQueueLoadingMore = true
|
||||
else this.workQueueLoading = true
|
||||
this.workQueueFilter = filter
|
||||
const response = await nuiCall<CompanyWorkQueuePage>(
|
||||
'companies:work-queue',
|
||||
{
|
||||
cursor: append ? this.workQueueNextCursor : null,
|
||||
filter,
|
||||
},
|
||||
)
|
||||
this.workQueueLoading = false
|
||||
this.workQueueLoadingMore = false
|
||||
if (!response.success || !response.data) {
|
||||
this.workQueueError = response.error ?? 'request_failed'
|
||||
if (!append) {
|
||||
this.workQueue = []
|
||||
this.workQueueNextCursor = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.workQueue = append
|
||||
? mergeById(this.workQueue, response.data.requests)
|
||||
: response.data.requests
|
||||
this.workQueueNextCursor = response.data.nextCursor
|
||||
this.workQueueError = ''
|
||||
return true
|
||||
},
|
||||
async loadMembers(): Promise<boolean> {
|
||||
this.membersLoading = true
|
||||
const response = await nuiCall<CompanyMembersResult>(
|
||||
'companies:list-members',
|
||||
)
|
||||
this.membersLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.mutationError = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.members = response.data.members
|
||||
this.mutationError = ''
|
||||
return true
|
||||
},
|
||||
async createRequest(
|
||||
draft: CreateCompanyRequest,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:create-request', draft)
|
||||
},
|
||||
async cancelRequest(
|
||||
requestId: string,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:cancel-request', {
|
||||
requestId,
|
||||
revision,
|
||||
})
|
||||
},
|
||||
async sendMessage(
|
||||
requestId: string,
|
||||
body: string,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:send-message', {
|
||||
body,
|
||||
requestId,
|
||||
revision,
|
||||
})
|
||||
},
|
||||
async claimRequest(
|
||||
requestId: string,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:claim-request', {
|
||||
requestId,
|
||||
revision,
|
||||
})
|
||||
},
|
||||
async assignRequest(
|
||||
requestId: string,
|
||||
memberId: string,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:assign-request', {
|
||||
memberId,
|
||||
requestId,
|
||||
revision,
|
||||
})
|
||||
},
|
||||
async updateRequestStatus(
|
||||
requestId: string,
|
||||
status: CompanyRequestStatus,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
return this.mutateRequest('companies:update-request-status', {
|
||||
requestId,
|
||||
revision,
|
||||
status,
|
||||
})
|
||||
},
|
||||
async updateAvailability(
|
||||
availability: CompanyAvailability,
|
||||
revision: number,
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
return this.mutateCompany('companies:update-availability', {
|
||||
availability,
|
||||
revision,
|
||||
})
|
||||
},
|
||||
async updateProfile(
|
||||
profile: UpdateCompanyProfile,
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
return this.mutateCompany('companies:update-profile', profile)
|
||||
},
|
||||
async updateHours(
|
||||
revision: number,
|
||||
hours: CompanyHours[],
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
return this.mutateCompany('companies:update-hours', { hours, revision })
|
||||
},
|
||||
async updateServices(
|
||||
revision: number,
|
||||
services: CompanyService[],
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
return this.mutateCompany('companies:update-services', {
|
||||
revision,
|
||||
services,
|
||||
})
|
||||
},
|
||||
async publishAnnouncement(
|
||||
announcement: PublishCompanyAnnouncement,
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
return this.mutateCompany('companies:publish-announcement', announcement)
|
||||
},
|
||||
async setCallAvailability(
|
||||
available: boolean,
|
||||
): Promise<NuiResponse<{ context: CompanyWorkContext }>> {
|
||||
this.mutating = true
|
||||
const response = await nuiCall<{ context: CompanyWorkContext }>(
|
||||
'companies:set-call-availability',
|
||||
{ available },
|
||||
)
|
||||
this.mutating = false
|
||||
if (!response.success || !response.data?.context) {
|
||||
this.mutationError = response.error ?? 'request_failed'
|
||||
return response
|
||||
}
|
||||
this.workContext = response.data.context
|
||||
this.workUnreadCount = Math.max(0, response.data.context.unreadCount)
|
||||
this.mutationError = ''
|
||||
return response
|
||||
},
|
||||
async callCustomer(requestId: string): Promise<NuiResponse<PhoneCall>> {
|
||||
this.mutating = true
|
||||
const response = await nuiCall<PhoneCall>('companies:call-customer', {
|
||||
requestId,
|
||||
})
|
||||
this.mutating = false
|
||||
this.mutationError = response.success
|
||||
? ''
|
||||
: (response.error ?? 'request_failed')
|
||||
return response
|
||||
},
|
||||
async mutateRequest(
|
||||
endpoint: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<NuiResponse<CompanyRequestMutationResult>> {
|
||||
this.mutating = true
|
||||
const deviceScopeVersion = this.deviceScopeVersion
|
||||
const response = await nuiCall<CompanyRequestMutationResult>(
|
||||
endpoint,
|
||||
payload,
|
||||
)
|
||||
this.mutating = false
|
||||
if (deviceScopeVersion !== this.deviceScopeVersion) {
|
||||
return { success: false, error: 'device_changed' }
|
||||
}
|
||||
if (!response.success || !response.data?.request) {
|
||||
this.mutationError = response.error ?? 'request_failed'
|
||||
return response
|
||||
}
|
||||
this.request = response.data.request
|
||||
this.replaceRequestSummary(response.data.request)
|
||||
if (response.data.context) {
|
||||
this.workContext = response.data.context
|
||||
this.workUnreadCount = Math.max(0, response.data.context.unreadCount)
|
||||
}
|
||||
this.mutationError = ''
|
||||
return response
|
||||
},
|
||||
async mutateCompany(
|
||||
endpoint: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<NuiResponse<CompanyMutationResult>> {
|
||||
this.mutating = true
|
||||
const response = await nuiCall<CompanyMutationResult>(endpoint, payload)
|
||||
this.mutating = false
|
||||
if (!response.success || !response.data?.company) {
|
||||
this.mutationError = response.error ?? 'request_failed'
|
||||
return response
|
||||
}
|
||||
this.company = response.data.company
|
||||
this.replaceCompanySummary(response.data.company)
|
||||
if (this.workContext) this.workContext.company = response.data.company
|
||||
if (response.data.context) {
|
||||
this.workContext = response.data.context
|
||||
this.workUnreadCount = Math.max(0, response.data.context.unreadCount)
|
||||
}
|
||||
this.mutationError = ''
|
||||
return response
|
||||
},
|
||||
replaceCompanySummary(company: Company): void {
|
||||
const index = this.directory.findIndex((item) => item.id === company.id)
|
||||
if (index >= 0) this.directory[index] = company
|
||||
},
|
||||
replaceRequestSummary(request: CompanyRequest): void {
|
||||
const lists = [this.myRequests, this.workQueue]
|
||||
for (const list of lists) {
|
||||
const index = list.findIndex((item) => item.id === request.id)
|
||||
if (index >= 0) list[index] = request
|
||||
}
|
||||
if (this.workContext) {
|
||||
const contextLists = [
|
||||
this.workContext.ownRequests,
|
||||
this.workContext.recentRequests,
|
||||
]
|
||||
for (const list of contextLists) {
|
||||
const index = list.findIndex((item) => item.id === request.id)
|
||||
if (index >= 0) list[index] = request
|
||||
}
|
||||
}
|
||||
},
|
||||
resetRequest(): void {
|
||||
this.request = null
|
||||
this.requestError = ''
|
||||
},
|
||||
resetDeviceScope(): void {
|
||||
this.deviceScopeVersion += 1
|
||||
this.customerUnreadCount = 0
|
||||
this.myRequests = []
|
||||
this.myRequestsError = ''
|
||||
this.myRequestsLoaded = false
|
||||
this.myRequestsLoading = false
|
||||
this.myRequestsLoadingMore = false
|
||||
this.myRequestsNextCursor = null
|
||||
this.request = null
|
||||
this.requestError = ''
|
||||
this.requestLoading = false
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -145,10 +145,11 @@ describe('notifications store', () => {
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
notifications.show({
|
||||
appId: 'mail',
|
||||
appId: 'companies',
|
||||
device: device('111'),
|
||||
route: '/apps/companies?requestId=request-1&area=customer',
|
||||
text: 'Store while closed',
|
||||
title: 'Mail',
|
||||
title: 'Companies',
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
@@ -158,9 +159,10 @@ describe('notifications store', () => {
|
||||
payload: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
appId: 'mail',
|
||||
appId: 'companies',
|
||||
route: '/apps/companies?requestId=request-1&area=customer',
|
||||
text: 'Store while closed',
|
||||
title: 'Mail',
|
||||
title: 'Companies',
|
||||
}),
|
||||
],
|
||||
version: 1,
|
||||
@@ -247,10 +249,7 @@ describe('notifications store', () => {
|
||||
})
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
notifications.hydrate(
|
||||
phone.device?.data.notifications?.payload,
|
||||
'111',
|
||||
)
|
||||
notifications.hydrate(phone.device?.data.notifications?.payload, '111')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(notifications.lockScreenNotifications[0].text).toBe('Saved message')
|
||||
|
||||
@@ -5,7 +5,10 @@ import { isPhoneAppId } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
import {
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
||||
type PhonePreferencesV1,
|
||||
} from '@/utils/preferences'
|
||||
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
|
||||
|
||||
export type PhoneNotificationDevice = {
|
||||
@@ -19,6 +22,7 @@ export type PhoneNotificationInput = {
|
||||
critical?: boolean
|
||||
device?: PhoneNotificationDevice
|
||||
persistent?: boolean
|
||||
route?: string
|
||||
sound?: PhoneToneId
|
||||
subtitle?: string
|
||||
text: string
|
||||
@@ -31,7 +35,7 @@ export type PhoneNotification = PhoneNotificationInput & {
|
||||
|
||||
type PersistedPhoneNotification = Pick<
|
||||
PhoneNotification,
|
||||
'appId' | 'id' | 'subtitle' | 'text' | 'title'
|
||||
'appId' | 'id' | 'route' | 'subtitle' | 'text' | 'title'
|
||||
>
|
||||
|
||||
type PersistedNotificationsV1 = {
|
||||
@@ -73,9 +77,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
|
||||
function persist(imei: string): void {
|
||||
const items = (lockScreenQueues.value[imei] ?? []).map(
|
||||
({ appId, id, subtitle, text, title }) => ({
|
||||
({ appId, id, route, subtitle, text, title }) => ({
|
||||
appId,
|
||||
id,
|
||||
...(route ? { route } : {}),
|
||||
...(subtitle ? { subtitle } : {}),
|
||||
text,
|
||||
title,
|
||||
@@ -108,7 +113,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
(payload as Partial<PersistedNotificationsV1>).version !== 1 ||
|
||||
!Array.isArray((payload as Partial<PersistedNotificationsV1>).items)
|
||||
) {
|
||||
console.error('[Phone notifications] Invalid persisted notification data.')
|
||||
console.error(
|
||||
'[Phone notifications] Invalid persisted notification data.',
|
||||
)
|
||||
} else {
|
||||
for (const item of (payload as PersistedNotificationsV1).items) {
|
||||
if (
|
||||
@@ -117,14 +124,21 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
!isPhoneAppId(item.appId) ||
|
||||
typeof item?.text !== 'string' ||
|
||||
typeof item?.title !== 'string' ||
|
||||
(item.route !== undefined &&
|
||||
(typeof item.route !== 'string' ||
|
||||
(item.route !== `/apps/${item.appId}` &&
|
||||
!item.route.startsWith(`/apps/${item.appId}?`)))) ||
|
||||
(item.subtitle !== undefined && typeof item.subtitle !== 'string')
|
||||
) {
|
||||
console.error('[Phone notifications] Ignored an invalid persisted notification.')
|
||||
console.error(
|
||||
'[Phone notifications] Ignored an invalid persisted notification.',
|
||||
)
|
||||
continue
|
||||
}
|
||||
stored.push({
|
||||
appId: item.appId,
|
||||
id: item.id,
|
||||
...(item.route ? { route: item.route } : {}),
|
||||
...(item.subtitle ? { subtitle: item.subtitle } : {}),
|
||||
text: item.text,
|
||||
title: item.title,
|
||||
@@ -161,7 +175,8 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
function activate(notification: PhoneNotification): void {
|
||||
const preferences = notification.device?.preferences ?? phone.preferences
|
||||
const appPreferences =
|
||||
preferences.settings.notifications[notification.appId]
|
||||
preferences.settings.notifications[notification.appId] ??
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES
|
||||
if (appPreferences.sounds || notification.critical) {
|
||||
const sound = notification.sound ?? preferences.settings.notificationSound
|
||||
const volume = notification.critical
|
||||
@@ -218,15 +233,19 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
if (current.value) dismiss(current.value.id)
|
||||
}
|
||||
|
||||
function dismissFromLockScreen(id: string): void {
|
||||
function dismissFromLockScreen(
|
||||
id: string,
|
||||
targetImei = phone.device?.imei,
|
||||
): void {
|
||||
dismiss(id)
|
||||
const imei = phone.device?.imei
|
||||
if (!imei) return
|
||||
const notifications = lockScreenQueues.value[imei]
|
||||
if (!targetImei) return
|
||||
const notifications = lockScreenQueues.value[targetImei]
|
||||
if (!notifications) return
|
||||
const index = notifications.findIndex((notification) => notification.id === id)
|
||||
const index = notifications.findIndex(
|
||||
(notification) => notification.id === id,
|
||||
)
|
||||
if (index >= 0) notifications.splice(index, 1)
|
||||
persist(imei)
|
||||
persist(targetImei)
|
||||
}
|
||||
|
||||
function clearLockScreen(): void {
|
||||
@@ -248,12 +267,14 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
}
|
||||
|
||||
function show(input: PhoneNotificationInput): string | null {
|
||||
const preferences = input.device?.preferences ?? phone.preferences
|
||||
const appPreferences = preferences.settings.notifications[input.appId]
|
||||
if (!appPreferences) {
|
||||
if (!isPhoneAppId(input.appId)) {
|
||||
console.error(`[Phone notifications] Unknown app: ${input.appId}`)
|
||||
return null
|
||||
}
|
||||
const preferences = input.device?.preferences ?? phone.preferences
|
||||
const appPreferences =
|
||||
preferences.settings.notifications[input.appId] ??
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES
|
||||
if (!input.critical && preferences.settings.focusMode) return null
|
||||
if (!input.critical && !appPreferences.enabled) {
|
||||
return null
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
ensureAppNotificationPreferences,
|
||||
parsePhonePreferences,
|
||||
type AppNotificationPreferences,
|
||||
type PhonePreferencesV1,
|
||||
@@ -38,8 +39,314 @@ export type PhoneOpenPayload = {
|
||||
|
||||
const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const companiesFallbackLocales = {
|
||||
name: 'Companies',
|
||||
navigation: 'Companies navigation',
|
||||
actions: 'Request actions',
|
||||
request: 'Service Request',
|
||||
verified: 'Verified company',
|
||||
back: 'Back',
|
||||
close: 'Close',
|
||||
tryAgain: 'Try Again',
|
||||
loadMore: 'Load More',
|
||||
routeSet: 'GPS route set.',
|
||||
tabs: {
|
||||
directory: 'Discover',
|
||||
requests: 'Requests',
|
||||
work: 'Work',
|
||||
},
|
||||
availability: {
|
||||
available: 'Available',
|
||||
busy: 'Busy',
|
||||
closed: 'Closed',
|
||||
},
|
||||
requestStatuses: {
|
||||
new: 'New',
|
||||
assigned: 'Assigned',
|
||||
in_progress: 'In Progress',
|
||||
waiting_customer: 'Waiting',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
roles: {
|
||||
employee: 'Employee',
|
||||
manager: 'Manager',
|
||||
},
|
||||
categories: {
|
||||
gastronomy: 'Food & Drink',
|
||||
vehicles: 'Vehicles',
|
||||
transport: 'Transport',
|
||||
crafts: 'Trades',
|
||||
retail: 'Retail',
|
||||
real_estate: 'Real Estate',
|
||||
media: 'Media',
|
||||
nightlife: 'Nightlife',
|
||||
public_services: 'Public Services',
|
||||
emergency: 'Emergency Services',
|
||||
medical: 'Medical',
|
||||
mechanics: 'Mechanics',
|
||||
government: 'Government',
|
||||
},
|
||||
days: {
|
||||
monday: 'Monday',
|
||||
tuesday: 'Tuesday',
|
||||
wednesday: 'Wednesday',
|
||||
thursday: 'Thursday',
|
||||
friday: 'Friday',
|
||||
saturday: 'Saturday',
|
||||
sunday: 'Sunday',
|
||||
},
|
||||
directory: {
|
||||
searchPlaceholder: 'Search companies or services',
|
||||
categories: 'Company categories',
|
||||
allCategories: 'All',
|
||||
allCompanies: 'All Companies',
|
||||
availableNow: 'Available now',
|
||||
availableNowHint: 'Only show companies taking calls right now.',
|
||||
availableSection: 'Available Now',
|
||||
moreFilters: 'More filters',
|
||||
hasLocation: 'Location',
|
||||
acceptsRequests: 'Requests',
|
||||
resetFilters: 'Reset Filters',
|
||||
},
|
||||
loading: {
|
||||
directory: 'Loading companies...',
|
||||
profile: 'Loading company profile...',
|
||||
request: 'Loading request...',
|
||||
requests: 'Loading your requests...',
|
||||
work: 'Loading workspace...',
|
||||
},
|
||||
states: {
|
||||
directoryError: 'Companies are unavailable',
|
||||
requestsError: 'Requests are unavailable',
|
||||
workError: 'Workspace unavailable',
|
||||
profileError: 'Company unavailable',
|
||||
requestError: 'Request unavailable',
|
||||
noCompanies: 'No companies listed',
|
||||
noCompaniesBody: 'Public companies will appear here when configured.',
|
||||
noResults: 'No matching companies',
|
||||
noResultsBody: 'Try another search or reset your filters.',
|
||||
noOpenRequests: 'No open requests',
|
||||
noClosedRequests: 'No completed requests',
|
||||
noRequestsBody: 'Requests you create with a company will appear here.',
|
||||
},
|
||||
actionUnavailable: {
|
||||
call: 'This company cannot be called right now.',
|
||||
message: 'This service line does not accept text messages.',
|
||||
route: 'This company has no public location.',
|
||||
request: 'This company is not accepting service requests.',
|
||||
},
|
||||
profile: {
|
||||
updated: 'Status updated {time}',
|
||||
announcement: 'Latest Update',
|
||||
location: 'Location',
|
||||
noLocation: 'No public location',
|
||||
hours: 'Opening Hours',
|
||||
closed: 'Closed',
|
||||
byAvailability: 'Open by availability',
|
||||
services: 'Services',
|
||||
noServices: 'No public services listed',
|
||||
call: 'Call',
|
||||
message: 'Message',
|
||||
route: 'Route',
|
||||
request: 'Request',
|
||||
},
|
||||
composer: {
|
||||
title: 'New Service Request',
|
||||
chooseService: 'Choose a Service',
|
||||
subject: 'Subject',
|
||||
subjectPlaceholder: 'What do you need?',
|
||||
description: 'Details',
|
||||
descriptionPlaceholder:
|
||||
'Describe what happened and how the company can help.',
|
||||
contact: 'Contact number',
|
||||
registeredSim: 'Replies go to your registered SIM {number}.',
|
||||
registeredSimRequired: 'A registered SIM is required to send a request.',
|
||||
addPhotos: 'Add Photos ({count}/3)',
|
||||
selectedPhoto: 'Selected request photo',
|
||||
removePhoto: 'Remove photo',
|
||||
review: 'Review Request',
|
||||
confirmTitle: 'Send this request?',
|
||||
confirmBody: 'Send your {service} request to {company}.',
|
||||
edit: 'Keep Editing',
|
||||
send: 'Send Request',
|
||||
},
|
||||
requests: {
|
||||
open: 'Open',
|
||||
closed: 'Completed',
|
||||
findCompany: 'Find a Company',
|
||||
generalService: 'General service',
|
||||
attachments: 'Request photos',
|
||||
attachedPhoto: 'Attached request photo',
|
||||
timeline: 'Timeline',
|
||||
conversation: 'Conversation',
|
||||
noMessages: 'No replies yet',
|
||||
replyPlaceholder: 'Write a reply',
|
||||
sendReply: 'Send reply',
|
||||
call: 'Call Company',
|
||||
cancel: 'Cancel Request',
|
||||
cancelTitle: 'Cancel this request?',
|
||||
cancelBody: 'The company will be notified and can no longer complete it.',
|
||||
keep: 'Keep Request',
|
||||
cancelConfirm: 'Cancel Request',
|
||||
},
|
||||
timeline: {
|
||||
created: 'Request created',
|
||||
assigned: 'Request assigned',
|
||||
cancelled: 'Request cancelled',
|
||||
completed: 'Request completed',
|
||||
statusChanged: 'Status changed to {status}',
|
||||
},
|
||||
time: {
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{count} min ago',
|
||||
hoursAgo: '{count} hr ago',
|
||||
daysAgo: '{count} days ago',
|
||||
},
|
||||
work: {
|
||||
notAuthorized: 'No company workspace',
|
||||
notAuthorizedBody:
|
||||
'Your current job is not connected to a configured company.',
|
||||
workspace: 'Company Workspace',
|
||||
publicAvailability: 'Public Availability',
|
||||
takeCalls: 'Take company calls',
|
||||
takeCallsBody: 'Route new service-line calls to this active SIM.',
|
||||
overview: 'Today at a Glance',
|
||||
metrics: {
|
||||
new: 'New',
|
||||
assigned: 'Mine',
|
||||
waiting: 'Waiting',
|
||||
completedToday: 'Done Today',
|
||||
},
|
||||
queue: 'Request Queue',
|
||||
filters: {
|
||||
new: 'New',
|
||||
assigned: 'Mine',
|
||||
in_progress: 'In Progress',
|
||||
waiting_customer: 'Waiting',
|
||||
completed: 'Done',
|
||||
},
|
||||
unassigned: 'Unassigned',
|
||||
emptyQueue: 'Queue is clear',
|
||||
emptyQueueBody: 'No requests match this filter.',
|
||||
},
|
||||
workActions: {
|
||||
claim: 'Claim Request',
|
||||
assign: 'Assign Employee',
|
||||
callCustomer: 'Call Customer',
|
||||
setStatus: 'Set status: {status}',
|
||||
},
|
||||
assignment: {
|
||||
title: 'Assign Employee',
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
unknownMember: 'Unknown employee',
|
||||
confirm: 'Assign Request',
|
||||
},
|
||||
assignmentLabels: {
|
||||
you: 'Assigned to you',
|
||||
assigned: 'Assigned to a colleague',
|
||||
},
|
||||
messageAuthors: {
|
||||
you: 'You',
|
||||
customer: 'Customer',
|
||||
company: 'Company',
|
||||
},
|
||||
manager: {
|
||||
title: 'Company Profile',
|
||||
subtitle: 'Manage public information, services, and announcements.',
|
||||
revision: 'Server revision {revision}',
|
||||
availability: 'Availability',
|
||||
profile: 'Public Profile',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Describe the company and what it offers.',
|
||||
phoneNumber: 'Service number',
|
||||
phoneNumberManaged: 'Configured by the server and cannot be edited here.',
|
||||
noPhoneNumber: 'No service number configured',
|
||||
chooseCover: 'Choose cover photo',
|
||||
chooseLogo: 'Choose logo',
|
||||
coverPhoto: 'Company cover photo',
|
||||
logoPhoto: 'Company logo',
|
||||
locationLabel: 'Location name',
|
||||
locationReady: 'A map position is attached to this profile.',
|
||||
useCurrentLocation: 'Use Current Location',
|
||||
address: 'Address',
|
||||
district: 'District',
|
||||
acceptRequests: 'Accept service requests',
|
||||
acceptRequestsBody: 'Allow registered SIMs to open structured requests.',
|
||||
saveProfile: 'Save Profile',
|
||||
hours: 'Opening Hours',
|
||||
dayOpen: 'Company is open on this day',
|
||||
opensAt: 'Opens',
|
||||
closesAt: 'Closes',
|
||||
saveHours: 'Save Hours',
|
||||
services: 'Services',
|
||||
serviceTitle: 'Service name',
|
||||
serviceDescription: 'Description',
|
||||
priceText: 'Price text',
|
||||
serviceActive: 'Publicly visible',
|
||||
serviceRequests: 'Accept requests for this service',
|
||||
removeService: 'Remove Service',
|
||||
addService: 'Add Service',
|
||||
saveServices: 'Save Services',
|
||||
announcement: 'Current Announcement',
|
||||
announcementText: 'Announcement',
|
||||
announcementPlaceholder: 'Share a short, timely update.',
|
||||
expiresAt: 'Expires at',
|
||||
publish: 'Publish Announcement',
|
||||
},
|
||||
feedback: {
|
||||
requestCreated: 'Request sent.',
|
||||
requestCancelled: 'Request cancelled.',
|
||||
requestClaimed: 'Request claimed.',
|
||||
requestAssigned: 'Request assigned.',
|
||||
statusUpdated: 'Request status updated.',
|
||||
availabilityUpdated: 'Public availability updated.',
|
||||
locationUpdated: 'Current location selected.',
|
||||
callsEnabled: 'Company calls enabled.',
|
||||
callsDisabled: 'Company calls disabled.',
|
||||
profileSaved: 'Company profile saved.',
|
||||
hoursSaved: 'Opening hours saved.',
|
||||
servicesSaved: 'Services saved.',
|
||||
announcementPublished: 'Announcement published.',
|
||||
},
|
||||
conflict: {
|
||||
title: 'Newer changes are available',
|
||||
body: 'Another manager updated this information. Reload before saving again.',
|
||||
reload: 'Reload',
|
||||
},
|
||||
notifications: {
|
||||
newRequest: 'New company request',
|
||||
requestUpdated: 'A company request was updated.',
|
||||
newMessage: 'New reply to your company request.',
|
||||
assigned: 'A company request was assigned to you.',
|
||||
},
|
||||
errors: {
|
||||
anonymous_sim: 'A registered SIM is required for service requests.',
|
||||
call_unavailable: 'This service line is currently unavailable.',
|
||||
company_not_found: 'This company is no longer available.',
|
||||
invalid_profile: 'Check the company profile fields.',
|
||||
invalid_request: 'Check the subject and request details.',
|
||||
invalid_media: 'One or more attached photos are unavailable.',
|
||||
invalid_expiration: 'Choose a valid announcement expiration.',
|
||||
invalid_service: 'This service is no longer available.',
|
||||
invalid_status: 'That status change is not allowed.',
|
||||
messaging_unavailable: 'This company does not accept text messages.',
|
||||
no_sim: 'Insert a SIM card to continue.',
|
||||
not_authorized: 'You are not authorized for this company action.',
|
||||
rate_limited: 'Please wait before trying again.',
|
||||
request_not_found: 'This request is no longer available.',
|
||||
revision_conflict:
|
||||
'The data changed on another device. Reload and try again.',
|
||||
service_unavailable: 'The Companies service is temporarily unavailable.',
|
||||
too_many_open_requests: 'You already have too many open requests.',
|
||||
request_failed: 'Companies could not complete the request.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
companies: companiesFallbackLocales,
|
||||
crewlink: {
|
||||
name: 'CrewLink',
|
||||
connecting: 'Connecting your crew...',
|
||||
@@ -62,8 +369,7 @@ const defaultLocales: LocaleTree = {
|
||||
noGroupBody:
|
||||
'Create a crew or join friends with a private invitation code.',
|
||||
createGroup: 'Create Group',
|
||||
createGroupBody:
|
||||
'Give your crew a recognizable name and signal colour.',
|
||||
createGroupBody: 'Give your crew a recognizable name and signal colour.',
|
||||
joinGroup: 'Join Group',
|
||||
joinWithCode: 'Join with Code',
|
||||
joinWithCodeBody:
|
||||
@@ -114,8 +420,7 @@ const defaultLocales: LocaleTree = {
|
||||
invite: 'Invite',
|
||||
inviteSent: 'Invitation sent to @{username}.',
|
||||
nobodyNearby: 'Nobody in range',
|
||||
nobodyNearbyBody:
|
||||
'Move closer to another CrewLink user and scan again.',
|
||||
nobodyNearbyBody: 'Move closer to another CrewLink user and scan again.',
|
||||
scanAgain: 'Scan Again',
|
||||
liveCoordination: 'Live coordination',
|
||||
noPings: 'No active pings',
|
||||
@@ -1066,6 +1371,8 @@ const defaultLocales: LocaleTree = {
|
||||
message: 'Message',
|
||||
send: 'Send',
|
||||
details: 'Details',
|
||||
officialContact: 'Official company contact',
|
||||
messagingUnavailable: 'This company contact does not accept messages.',
|
||||
filterUnread: 'Show Unread Messages',
|
||||
smsLabel: 'Text Message · SMS',
|
||||
photo: 'Photo',
|
||||
@@ -1135,6 +1442,7 @@ const defaultLocales: LocaleTree = {
|
||||
gif_provider_failed: 'GIF search is temporarily unavailable.',
|
||||
self_message: 'You cannot message your own number.',
|
||||
recipient_not_found: 'That number is unavailable.',
|
||||
messaging_unavailable: 'This company contact does not accept messages.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
rate_limited: 'Too many messages. Try again in a minute.',
|
||||
request_failed: 'Messages are temporarily unavailable.',
|
||||
@@ -1170,6 +1478,7 @@ const defaultLocales: LocaleTree = {
|
||||
editContact: 'Edit Contact',
|
||||
contactName: 'Name',
|
||||
phoneNumber: 'Phone Number',
|
||||
officialContact: 'Official company contact',
|
||||
call: 'Call',
|
||||
calling: 'calling...',
|
||||
incoming: 'Incoming Call',
|
||||
@@ -1198,10 +1507,13 @@ const defaultLocales: LocaleTree = {
|
||||
errors: {
|
||||
invalid_contact: 'Enter a name and valid phone number.',
|
||||
invalid_number: 'Enter a valid phone number.',
|
||||
invalid_sim: 'The active SIM card is unavailable.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
airplane_mode: 'Turn off Airplane Mode to make calls.',
|
||||
self_call: 'You cannot call your own number.',
|
||||
busy: 'The line is busy.',
|
||||
company_unavailable: 'This company service line is unavailable.',
|
||||
readonly_contact: 'Official company contacts cannot be changed.',
|
||||
rate_limited: 'Too many calls. Try again in a minute.',
|
||||
voice_unavailable: 'The configured phone voice service is unavailable.',
|
||||
inventory_full: 'There is no room for the ejected SIM card.',
|
||||
@@ -2973,7 +3285,11 @@ const defaultLocales: LocaleTree = {
|
||||
stop: 'Stop',
|
||||
use: 'Use',
|
||||
},
|
||||
Notifications: { now: 'now' },
|
||||
Notifications: {
|
||||
clearAll: 'Clear All',
|
||||
now: 'now',
|
||||
open: 'Open notification',
|
||||
},
|
||||
LockScreen: {
|
||||
label: 'Lock Screen',
|
||||
flashlight: 'Flashlight',
|
||||
@@ -3173,6 +3489,9 @@ export const usePhoneStore = defineStore('phone', {
|
||||
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
|
||||
this.launchOrigin = origin
|
||||
},
|
||||
ensureAppNotificationPreferences(appIds: LaunchablePhoneAppId[]): void {
|
||||
ensureAppNotificationPreferences(this.preferences, appIds)
|
||||
},
|
||||
setAppNotification(
|
||||
appId: LaunchablePhoneAppId,
|
||||
key: keyof AppNotificationPreferences,
|
||||
|
||||
Reference in New Issue
Block a user