ENH - integrate latest dev into EasyShare

This commit is contained in:
smx.pusha
2026-08-12 06:43:14 +02:00
84 changed files with 18328 additions and 454 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,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,