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:
Leon.Schmidt
2026-08-11 00:22:39 +02:00
parent 72e5f91e02
commit 42ac955052
77 changed files with 17630 additions and 333 deletions
+135
View File
@@ -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 },
])
})
})
+314
View File
@@ -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]
}
},
},
})
+64 -14
View File
@@ -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()
})
})
+159 -26
View File
@@ -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()
+327
View File
@@ -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',
})
})
})
+539
View File
@@ -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
},
},
})
+7 -8
View File
@@ -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')
+36 -15
View File
@@ -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
+324 -5
View File
@@ -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,