mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge remote-tracking branch 'origin/dev' into benja/app-overhaul
# Conflicts: # frontend/src/stores/phone.ts # frontend/src/views/apps/CrewLinkApp.vue
This commit is contained in:
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import type { BankingOverview } from '@/types/banking'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
@@ -60,4 +60,26 @@ describe('banking store', () => {
|
||||
expect(banking.overview).toEqual(overview)
|
||||
expect(banking.error).toBe('insufficient_funds')
|
||||
})
|
||||
|
||||
it('does not let an older response overwrite the newest overview', async () => {
|
||||
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
|
||||
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
|
||||
(resolve) => {
|
||||
resolveOlder = resolve
|
||||
},
|
||||
)
|
||||
const newest = { ...overview, bank: 23000 }
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(olderResponse)
|
||||
.mockResolvedValueOnce({ data: newest, success: true })
|
||||
const banking = useBankingStore()
|
||||
|
||||
const olderRequest = banking.load()
|
||||
await banking.load()
|
||||
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
|
||||
await olderRequest
|
||||
|
||||
expect(banking.overview).toEqual(newest)
|
||||
expect(banking.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
|
||||
error: '',
|
||||
isLoading: false,
|
||||
overview: null as BankingOverview | null,
|
||||
pendingRequests: 0,
|
||||
requestGeneration: 0,
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>('banking:overview')
|
||||
this.isLoading = false
|
||||
const response = await nuiCall<BankingOverview>('banking:overview').finally(
|
||||
() => {
|
||||
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
||||
this.isLoading = this.pendingRequests > 0
|
||||
},
|
||||
)
|
||||
if (generation !== this.requestGeneration) return response.success
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
|
||||
amount: number,
|
||||
phoneNumber?: string,
|
||||
): Promise<NuiResponse<BankingOverview>> {
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
|
||||
amount,
|
||||
...(phoneNumber === undefined ? {} : { phoneNumber }),
|
||||
}).finally(() => {
|
||||
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
||||
this.isLoading = this.pendingRequests > 0
|
||||
})
|
||||
this.isLoading = false
|
||||
if (generation !== this.requestGeneration) return response
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import type { MailCounts, MailListItem } from '@/types/mail'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
@@ -45,7 +46,7 @@ describe('mail store', () => {
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [listItem(2)] },
|
||||
data: { hasMore: false, items: [listItem(2)], offset: 0 },
|
||||
success: true,
|
||||
})
|
||||
|
||||
@@ -129,4 +130,103 @@ describe('mail store', () => {
|
||||
expect(mail.folder).toBe('inbox')
|
||||
expect(mail.search).toBe('')
|
||||
})
|
||||
|
||||
it('ignores an older folder response after a newer navigation', async () => {
|
||||
let resolveOlder!: (response: NuiResponse<MailListResponse>) => void
|
||||
const olderResponse = new Promise<NuiResponse<MailListResponse>>(
|
||||
(resolve) => {
|
||||
resolveOlder = resolve
|
||||
},
|
||||
)
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(olderResponse)
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [listItem(2)] },
|
||||
success: true,
|
||||
})
|
||||
const mail = useMailStore()
|
||||
|
||||
const olderRequest = mail.loadFolder('inbox')
|
||||
await mail.loadFolder('sent')
|
||||
resolveOlder({
|
||||
data: { hasMore: false, items: [listItem(1)], offset: 0 },
|
||||
success: true,
|
||||
})
|
||||
await olderRequest
|
||||
|
||||
expect(mail.folder).toBe('sent')
|
||||
expect(mail.items.map((item) => item.id)).toEqual([2])
|
||||
expect(mail.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores mailbox counts returned after the session was cleared', async () => {
|
||||
let resolveCounts!: (response: NuiResponse<MailCounts>) => void
|
||||
mockNuiCall.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<MailCounts>>((resolve) => {
|
||||
resolveCounts = resolve
|
||||
}),
|
||||
)
|
||||
const mail = useMailStore()
|
||||
|
||||
const bootstrap = mail.bootstrap('alex@ifruit.com')
|
||||
await mail.bootstrap('')
|
||||
resolveCounts({ data: counts, success: true })
|
||||
await bootstrap
|
||||
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(mail.counts).toEqual({
|
||||
drafts: 0,
|
||||
inbox: 0,
|
||||
sent: 0,
|
||||
trash: 0,
|
||||
unread: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a late login after the mailbox session was cleared', async () => {
|
||||
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
|
||||
mockNuiCall.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
|
||||
resolveLogin = resolve
|
||||
}),
|
||||
)
|
||||
const mail = useMailStore()
|
||||
const account = useAccountStore()
|
||||
|
||||
const login = mail.login('alex', 'secret')
|
||||
await mail.bootstrap('')
|
||||
resolveLogin({
|
||||
data: { devices: [], email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
await login
|
||||
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(account.email).toBe('')
|
||||
})
|
||||
|
||||
it('ignores a late login after an external mailbox session change', async () => {
|
||||
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
|
||||
resolveLogin = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ data: counts, success: true })
|
||||
const mail = useMailStore()
|
||||
const account = useAccountStore()
|
||||
|
||||
const login = mail.login('alex', 'secret')
|
||||
account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
|
||||
await mail.bootstrap('morgan@ifruit.com')
|
||||
resolveLogin({
|
||||
data: { devices: [], email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
await login
|
||||
|
||||
expect(mail.accountEmail).toBe('morgan@ifruit.com')
|
||||
expect(account.email).toBe('morgan@ifruit.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
|
||||
const items = ref<MailListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
let authenticationGeneration = 0
|
||||
let folderRequestGeneration = 0
|
||||
let sessionGeneration = 0
|
||||
|
||||
function clearSession(): void {
|
||||
authenticationGeneration += 1
|
||||
sessionGeneration += 1
|
||||
folderRequestGeneration += 1
|
||||
accountEmail.value = ''
|
||||
counts.value = emptyCounts()
|
||||
items.value = []
|
||||
hasMore.value = false
|
||||
folder.value = 'inbox'
|
||||
search.value = ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function bootstrap(email: string): Promise<void> {
|
||||
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
authenticationGeneration += 1
|
||||
sessionGeneration += 1
|
||||
folderRequestGeneration += 1
|
||||
accountEmail.value = email
|
||||
await refreshCounts()
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const generation = ++authenticationGeneration
|
||||
const response = await nuiCall<IfruitAccount>('mail:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
if (
|
||||
generation === authenticationGeneration &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const generation = ++authenticationGeneration
|
||||
const response = await nuiCall<IfruitAccount>('mail:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
if (
|
||||
generation === authenticationGeneration &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
const generation = ++authenticationGeneration
|
||||
if (accountEmail.value) {
|
||||
const response = await nuiCall('mail:logout')
|
||||
if (generation !== authenticationGeneration) return
|
||||
if (response.success) account.hydrate(null)
|
||||
}
|
||||
if (generation !== authenticationGeneration) return
|
||||
clearSession()
|
||||
}
|
||||
|
||||
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
|
||||
nextSearch = '',
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
const generation = ++folderRequestGeneration
|
||||
const session = sessionGeneration
|
||||
loading.value = true
|
||||
const offset = append ? items.value.length : 0
|
||||
const response = await nuiCall<MailListResponse>('mail:list', {
|
||||
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
offset,
|
||||
search: nextSearch,
|
||||
})
|
||||
loading.value = false
|
||||
if (generation === folderRequestGeneration) loading.value = false
|
||||
if (
|
||||
generation !== folderRequestGeneration ||
|
||||
session !== sessionGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!response.success || !response.data) return false
|
||||
|
||||
folder.value = nextFolder
|
||||
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function refreshCounts(): Promise<void> {
|
||||
const email = accountEmail.value
|
||||
const session = sessionGeneration
|
||||
const response = await nuiCall<MailCounts>('mail:counts')
|
||||
if (response.success && response.data) counts.value = response.data
|
||||
if (
|
||||
session === sessionGeneration &&
|
||||
email === accountEmail.value &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
counts.value = response.data
|
||||
}
|
||||
}
|
||||
|
||||
async function openMessage(id: number): Promise<MailMessage | null> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
MAX_LOCK_SCREEN_NOTIFICATIONS,
|
||||
useNotificationsStore,
|
||||
type PhoneNotificationDevice,
|
||||
} from '@/stores/notifications'
|
||||
@@ -256,4 +257,26 @@ describe('notifications store', () => {
|
||||
notifications.clearLockScreen()
|
||||
expect(notifications.lockScreenNotifications).toEqual([])
|
||||
})
|
||||
|
||||
it('bounds persisted lock screen history to the newest notifications', () => {
|
||||
openPhone('111')
|
||||
const notifications = useNotificationsStore()
|
||||
const items = Array.from(
|
||||
{ length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 },
|
||||
(_, index) => ({
|
||||
appId: 'mail' as const,
|
||||
id: `saved-${index}`,
|
||||
text: `Message ${index}`,
|
||||
title: 'Mail',
|
||||
}),
|
||||
)
|
||||
|
||||
notifications.hydrate({ items, version: 1 }, '111')
|
||||
|
||||
expect(notifications.lockScreenNotifications).toHaveLength(
|
||||
MAX_LOCK_SCREEN_NOTIFICATIONS,
|
||||
)
|
||||
expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59')
|
||||
expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
|
||||
version: 1
|
||||
}
|
||||
|
||||
export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
|
||||
|
||||
const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const stopToneHandles = new Map<string, () => void>()
|
||||
const persistenceQueues = new Map<string, Promise<void>>()
|
||||
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
for (const notification of stored) merged.set(notification.id, notification)
|
||||
for (const notification of lockScreenQueues.value[imei] ?? [])
|
||||
merged.set(notification.id, notification)
|
||||
lockScreenQueues.value[imei] = [...merged.values()]
|
||||
lockScreenQueues.value[imei] = [...merged.values()].slice(
|
||||
-MAX_LOCK_SCREEN_NOTIFICATIONS,
|
||||
)
|
||||
persist(imei)
|
||||
}
|
||||
|
||||
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
function remember(notification: PhoneNotification): void {
|
||||
const imei = notification.device?.imei ?? phone.device?.imei
|
||||
if (!imei) return
|
||||
const notifications = lockScreenQueues.value[imei] ?? []
|
||||
notifications.push(notification)
|
||||
lockScreenQueues.value[imei] = notifications
|
||||
lockScreenQueues.value[imei] = [
|
||||
...(lockScreenQueues.value[imei] ?? []),
|
||||
notification,
|
||||
].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
|
||||
persist(imei)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
function deferredResponse<T>(): {
|
||||
promise: Promise<NuiResponse<T>>
|
||||
resolve: (response: NuiResponse<T>) => void
|
||||
} {
|
||||
let resolve!: (response: NuiResponse<T>) => void
|
||||
const promise = new Promise<NuiResponse<T>>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function openPhone(imei: string, token: string, revision: number): void {
|
||||
usePhoneStore().open({
|
||||
device: {
|
||||
data: { settings: { payload: {}, revision } },
|
||||
imei,
|
||||
name: `Phone ${imei}`,
|
||||
sim: null,
|
||||
},
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
describe('phone device persistence scope', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not apply a late save response to a newer device session', async () => {
|
||||
const stale = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall.mockReturnValueOnce(stale.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 2)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { value: 'old' })
|
||||
await Promise.resolve()
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
|
||||
imei: '111',
|
||||
namespace: 'settings',
|
||||
payload: { value: 'old' },
|
||||
revision: 2,
|
||||
sessionToken: 'session-a',
|
||||
})
|
||||
|
||||
openPhone('222', 'session-b', 7)
|
||||
stale.resolve({ data: { revision: 3 }, success: true })
|
||||
await stale.promise
|
||||
await Promise.resolve()
|
||||
|
||||
expect(phone.device?.imei).toBe('222')
|
||||
expect(phone.deviceRevisions.settings).toBe(7)
|
||||
})
|
||||
|
||||
it('drops queued writes from an obsolete device generation', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall.mockReturnValueOnce(first.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
openPhone('222', 'session-b', 0)
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flushes every queued write after a normal visibility close', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
phone.close()
|
||||
const flushed = phone.flushDevicePersistence()
|
||||
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await flushed
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
|
||||
imei: '111',
|
||||
namespace: 'settings',
|
||||
payload: { order: 2 },
|
||||
revision: 1,
|
||||
sessionToken: 'session-a',
|
||||
})
|
||||
expect(phone.deviceRevisions.settings).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps queued writes scoped across a same-session bootstrap update', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
openPhone('111', 'session-a', 1)
|
||||
await phone.flushDevicePersistence()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(phone.deviceRevisions.settings).toBe(2)
|
||||
})
|
||||
|
||||
it('waits for writes queued while a persistence flush is in progress', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
const queuedDuringFlush = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(queuedDuringFlush.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
await Promise.resolve()
|
||||
let flushCompleted = false
|
||||
const flushed = phone.flushDevicePersistence().then(() => {
|
||||
flushCompleted = true
|
||||
})
|
||||
phone.saveDeviceNamespace('widgets', { order: 2 })
|
||||
await Promise.resolve()
|
||||
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
expect(flushCompleted).toBe(false)
|
||||
|
||||
queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
|
||||
await flushed
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(phone.deviceRevisions.widgets).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
clampPhoneScale,
|
||||
ensureAppNotificationPreferences,
|
||||
parsePhonePreferences,
|
||||
type AppNotificationPreferences,
|
||||
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
|
||||
}
|
||||
|
||||
const namespaceQueues = new Map<string, Promise<void>>()
|
||||
let nextPersistenceSession = 0
|
||||
|
||||
const companiesFallbackLocales = {
|
||||
name: 'Companies',
|
||||
@@ -3688,11 +3690,14 @@ export const usePhoneStore = defineStore('phone', {
|
||||
currentPage: 1,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
deviceSessionToken: null as string | null,
|
||||
isOpen: false,
|
||||
lang: 'en',
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
persistenceGeneration: 0,
|
||||
persistenceSession: ++nextPersistenceSession,
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -3713,6 +3718,15 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.isOpen = false
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
if (
|
||||
nextImei !== (this.device?.imei ?? null) ||
|
||||
nextToken !== this.deviceSessionToken
|
||||
) {
|
||||
this.persistenceGeneration += 1
|
||||
}
|
||||
this.deviceSessionToken = nextToken
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
@@ -3723,6 +3737,13 @@ export const usePhoneStore = defineStore('phone', {
|
||||
}
|
||||
this.isOpen = true
|
||||
},
|
||||
endDeviceSession(): void {
|
||||
this.close()
|
||||
if (this.deviceSessionToken !== null) {
|
||||
this.deviceSessionToken = null
|
||||
this.persistenceGeneration += 1
|
||||
}
|
||||
},
|
||||
hydrateDevice(device: PhoneDevice): void {
|
||||
this.device = device
|
||||
this.deviceRevisions = Object.fromEntries(
|
||||
@@ -3736,22 +3757,59 @@ export const usePhoneStore = defineStore('phone', {
|
||||
)
|
||||
},
|
||||
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||||
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
|
||||
const imei = this.device?.imei
|
||||
if (!imei) {
|
||||
console.error(
|
||||
`[Phone persistence] Could not save ${namespace} without an active device.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const generation = this.persistenceGeneration
|
||||
const session = this.persistenceSession
|
||||
const token = this.deviceSessionToken
|
||||
const queuedPayload = cloneJsonData(payload)
|
||||
const queueKey = `${session}:${generation}:${imei}:${namespace}`
|
||||
const isCurrentScope = (): boolean =>
|
||||
this.persistenceSession === session &&
|
||||
this.persistenceGeneration === generation &&
|
||||
this.device?.imei === imei &&
|
||||
this.deviceSessionToken === token
|
||||
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
|
||||
const queued = previous.then(async () => {
|
||||
if (!isCurrentScope()) return
|
||||
const response = await nuiCall<{ revision: number }>('device:save', {
|
||||
imei,
|
||||
namespace,
|
||||
payload,
|
||||
payload: queuedPayload,
|
||||
revision: this.deviceRevisions[namespace] ?? 0,
|
||||
sessionToken: token,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
this.deviceRevisions[namespace] = response.data.revision
|
||||
if (
|
||||
isCurrentScope() &&
|
||||
response.success &&
|
||||
Number.isInteger(response.data?.revision) &&
|
||||
Number(response.data?.revision) >= 0
|
||||
) {
|
||||
this.deviceRevisions[namespace] = Number(response.data?.revision)
|
||||
}
|
||||
})
|
||||
const tracked = queued.finally(() => {
|
||||
if (namespaceQueues.get(namespace) === tracked)
|
||||
namespaceQueues.delete(namespace)
|
||||
if (namespaceQueues.get(queueKey) === tracked)
|
||||
namespaceQueues.delete(queueKey)
|
||||
})
|
||||
namespaceQueues.set(namespace, tracked)
|
||||
namespaceQueues.set(queueKey, tracked)
|
||||
},
|
||||
async flushDevicePersistence(): Promise<void> {
|
||||
const imei = this.device?.imei
|
||||
if (!imei) return
|
||||
const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
|
||||
while (true) {
|
||||
const activeQueues = [...namespaceQueues.entries()]
|
||||
.filter(([key]) => key.startsWith(queuePrefix))
|
||||
.map(([, queue]) => queue)
|
||||
if (!activeQueues.length) return
|
||||
await Promise.all(activeQueues)
|
||||
}
|
||||
},
|
||||
setCurrentPage(page: number, pageCount?: number): void {
|
||||
this.currentPage = clampPage(page, pageCount)
|
||||
@@ -3777,7 +3835,9 @@ export const usePhoneStore = defineStore('phone', {
|
||||
key: K,
|
||||
value: PhonePreferencesV1['settings'][K],
|
||||
): void {
|
||||
this.preferences.settings[key] = value
|
||||
this.preferences.settings[key] = (
|
||||
key === 'phoneScale' ? clampPhoneScale(Number(value)) : value
|
||||
) as PhonePreferencesV1['settings'][K]
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setAlertVolumes(value: number): void {
|
||||
|
||||
Reference in New Issue
Block a user