mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - integrate latest dev into EasyShare
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,6 +39,311 @@ 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: {
|
||||
easyShare: {
|
||||
@@ -104,6 +410,7 @@ const defaultLocales: LocaleTree = {
|
||||
request_failed: 'EasyShare is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
companies: companiesFallbackLocales,
|
||||
crewlink: {
|
||||
name: 'CrewLink',
|
||||
connecting: 'Connecting your crew...',
|
||||
@@ -126,8 +433,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:
|
||||
@@ -178,8 +484,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',
|
||||
@@ -1131,6 +1436,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',
|
||||
@@ -1207,6 +1514,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.',
|
||||
@@ -1258,6 +1566,7 @@ const defaultLocales: LocaleTree = {
|
||||
lastName: 'Last Name',
|
||||
companyOrGroup: 'Company or Group',
|
||||
phoneNumber: 'Phone Number',
|
||||
officialContact: 'Official company contact',
|
||||
choosePhoto: 'Choose Contact Photo',
|
||||
chooseGallery: 'Gallery',
|
||||
takePhoto: 'Camera',
|
||||
@@ -1316,6 +1625,7 @@ 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.',
|
||||
message_unavailable: 'The conversation could not be opened.',
|
||||
contact_remove_failed: 'The contact could not be removed.',
|
||||
contact_favorite_failed: 'The favorite could not be updated.',
|
||||
@@ -1325,6 +1635,8 @@ const defaultLocales: LocaleTree = {
|
||||
blocked: 'This number is blocked.',
|
||||
recipient_not_found: 'This number is not known.',
|
||||
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.',
|
||||
@@ -3096,7 +3408,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',
|
||||
@@ -3296,6 +3612,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