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:
smx.pusha
2026-08-12 21:23:48 +02:00
90 changed files with 5258 additions and 705 deletions
+23 -1
View File
@@ -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)
})
})
+17 -3
View File
@@ -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 = ''
+103 -3
View File
@@ -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')
})
})
+44 -4
View File
@@ -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> {
+23
View File
@@ -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')
})
})
+9 -4
View File
@@ -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)
})
})
+68 -8
View File
@@ -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 {