mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - merge latest dev into weather app
Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_INSTALLED_PHONE_APP_IDS,
|
||||
} from '@/config/apps'
|
||||
import { DEFAULT_INSTALLED_PHONE_APP_IDS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import {
|
||||
getHomeFolder,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
HOME_LAYOUT_VERSION,
|
||||
removeHomeApp,
|
||||
} from '@/utils/homeLayout'
|
||||
|
||||
@@ -112,7 +111,7 @@ describe('app store', () => {
|
||||
})
|
||||
|
||||
expect(apps.claimedApps).toEqual(['external-radio'])
|
||||
expect(apps.homeLayout.version).toBe(5)
|
||||
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
|
||||
expect(apps.homeLayout.grid).toContain('external-radio')
|
||||
const pageAppCount = apps.homeLayout.grid
|
||||
.slice(0, HOME_GRID_PAGE_SIZE)
|
||||
@@ -122,7 +121,7 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps external app state from current version 5 layouts', () => {
|
||||
it('keeps external app state from current layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
@@ -131,7 +130,8 @@ describe('app store', () => {
|
||||
dock: [],
|
||||
grid: ['external-radio'],
|
||||
hidden: [],
|
||||
version: 5,
|
||||
pageCount: 1,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
},
|
||||
launchCounts: { 'external-radio': 3 },
|
||||
})
|
||||
@@ -142,7 +142,7 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists a page-aware version 3 to version 5 migration once', () => {
|
||||
it('persists a page-aware version 3 migration once', () => {
|
||||
const apps = useAppStoreStore()
|
||||
const grid = Array.from({ length: 40 }, () => null as string | null)
|
||||
grid[20] = 'external-page-two'
|
||||
@@ -152,7 +152,7 @@ describe('app store', () => {
|
||||
homeLayout: { dock: [], grid, hidden: [], version: 3 },
|
||||
})
|
||||
|
||||
expect(apps.homeLayout.version).toBe(5)
|
||||
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
|
||||
expect(apps.homeLayout.grid[20]).not.toBe('external-page-two')
|
||||
expect(apps.homeLayout.grid[24]).toBe('external-page-two')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
@@ -331,6 +331,53 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('persists widget reflow without pulling apps back a page', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
const originalApps = apps.homeLayout.grid.filter(Boolean)
|
||||
|
||||
expect(
|
||||
apps.applyWidgetGridCapacities(
|
||||
[10, HOME_GRID_PAGE_SIZE],
|
||||
[HOME_GRID_PAGE_SIZE, 10],
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
apps.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE).filter(Boolean),
|
||||
).toEqual(originalApps.slice(0, 10))
|
||||
expect(
|
||||
apps.homeLayout.grid
|
||||
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
|
||||
.filter(Boolean),
|
||||
).toEqual(originalApps.slice(10))
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('persists and restores an otherwise empty extra page', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.addHomePage()).toBe(true)
|
||||
expect(apps.homeLayout.pageCount).toBe(2)
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
|
||||
const persisted = mocks.phone.saveDeviceNamespace.mock.calls[0]?.[1] as {
|
||||
homeLayout: typeof apps.homeLayout
|
||||
}
|
||||
apps.hydrate({
|
||||
...persisted,
|
||||
homeLayout: {
|
||||
...persisted.homeLayout,
|
||||
grid: persisted.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE),
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.homeLayout.pageCount).toBe(2)
|
||||
expect(apps.homeLayout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
|
||||
})
|
||||
|
||||
it('persists the complete folder lifecycle through store actions', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
parseHomeLayout,
|
||||
reflowHomeGridForWidgetChange,
|
||||
renameHomeFolder,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
@@ -230,7 +231,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
? (data.homeLayout as { version?: unknown }).version
|
||||
: undefined
|
||||
const supportsPersistedExternalApps =
|
||||
layoutVersion === 3 || layoutVersion === 4 || layoutVersion === 5
|
||||
layoutVersion === 3 ||
|
||||
layoutVersion === 4 ||
|
||||
layoutVersion === 5 ||
|
||||
layoutVersion === 6
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
@@ -291,7 +295,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
removedLegacyDefaults ||
|
||||
layoutVersion === 2 ||
|
||||
layoutVersion === 3 ||
|
||||
layoutVersion === 4
|
||||
layoutVersion === 4 ||
|
||||
layoutVersion === 5
|
||||
) {
|
||||
this.persist()
|
||||
}
|
||||
@@ -374,6 +379,22 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
applyWidgetGridCapacities(
|
||||
previousCapacities: readonly number[],
|
||||
nextCapacities: readonly number[],
|
||||
): boolean {
|
||||
const next = reflowHomeGridForWidgetChange(
|
||||
this.homeLayout,
|
||||
previousCapacities,
|
||||
nextCapacities,
|
||||
)
|
||||
if (!next) return false
|
||||
if (next !== this.homeLayout) {
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
}
|
||||
return true
|
||||
},
|
||||
createHomeFolder(
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('banking store', () => {
|
||||
expect(banking.error).toBe('insufficient_funds')
|
||||
})
|
||||
|
||||
it('does not let an older response overwrite the newest overview', async () => {
|
||||
it('does not let an older overview response overwrite a newer transfer', async () => {
|
||||
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
|
||||
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
|
||||
(resolve) => {
|
||||
@@ -75,7 +75,7 @@ describe('banking store', () => {
|
||||
const banking = useBankingStore()
|
||||
|
||||
const olderRequest = banking.load()
|
||||
await banking.load()
|
||||
await banking.perform('transfer', 1787, '5551234567')
|
||||
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
|
||||
await olderRequest
|
||||
|
||||
@@ -83,15 +83,62 @@ describe('banking store', () => {
|
||||
expect(banking.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks every banking request while a reload cooldown is active', async () => {
|
||||
it('coalesces concurrent overview reloads into one NUI request', async () => {
|
||||
let resolveLoad!: (response: NuiResponse<BankingOverview>) => void
|
||||
mockNuiCall.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<BankingOverview>>((resolve) => {
|
||||
resolveLoad = resolve
|
||||
}),
|
||||
)
|
||||
const banking = useBankingStore()
|
||||
|
||||
const firstLoad = banking.load()
|
||||
const secondLoad = banking.load()
|
||||
resolveLoad({ data: overview, success: true })
|
||||
|
||||
expect(await firstLoad).toBe(true)
|
||||
expect(await secondLoad).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('queues one fresh overview after a server-side balance change', async () => {
|
||||
let resolveActive!: (response: NuiResponse<BankingOverview>) => void
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<BankingOverview>>((resolve) => {
|
||||
resolveActive = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...overview, bank: overview.bank + 500 },
|
||||
success: true,
|
||||
})
|
||||
const banking = useBankingStore()
|
||||
|
||||
const activeLoad = banking.load()
|
||||
const changedLoad = banking.load(false, true)
|
||||
const duplicateChangedLoad = banking.load(false, true)
|
||||
resolveActive({ data: overview, success: true })
|
||||
|
||||
await activeLoad
|
||||
expect(await changedLoad).toBe(true)
|
||||
expect(await duplicateChangedLoad).toBe(true)
|
||||
expect(banking.overview?.bank).toBe(overview.bank + 500)
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('limits manual refreshes without blocking automatic loads or transfers', async () => {
|
||||
const banking = useBankingStore()
|
||||
banking.cooldownUntil = Date.now() + 10_000
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: overview, success: true })
|
||||
.mockResolvedValueOnce({ data: overview, success: true })
|
||||
|
||||
expect(await banking.load()).toBe(false)
|
||||
expect(await banking.perform('transfer', 100, '5551234567')).toEqual({
|
||||
error: 'reload_cooldown',
|
||||
success: false,
|
||||
})
|
||||
expect(mockNuiCall).not.toHaveBeenCalled()
|
||||
expect(await banking.load(true)).toBe(false)
|
||||
expect(await banking.load()).toBe(true)
|
||||
expect((await banking.perform('transfer', 100, '5551234567')).success).toBe(
|
||||
true,
|
||||
)
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
RELOAD_COOLDOWN_ERROR,
|
||||
} from '@/utils/reload-cooldown'
|
||||
|
||||
let activeOverviewLoad: Promise<boolean> | null = null
|
||||
let queuedOverviewLoad: Promise<boolean> | null = null
|
||||
|
||||
export const useBankingStore = defineStore('banking', {
|
||||
state: () => ({
|
||||
error: '',
|
||||
@@ -19,41 +22,59 @@ export const useBankingStore = defineStore('banking', {
|
||||
reloadAttempts: [] as number[],
|
||||
}),
|
||||
actions: {
|
||||
async load(manualReload = false): Promise<boolean> {
|
||||
async load(manualReload = false, ensureFresh = false): Promise<boolean> {
|
||||
if (activeOverviewLoad) {
|
||||
if (!ensureFresh) return activeOverviewLoad
|
||||
if (!queuedOverviewLoad) {
|
||||
const activeRequest = activeOverviewLoad
|
||||
const queuedRequest = activeRequest.then(
|
||||
() => this.load(),
|
||||
() => this.load(),
|
||||
)
|
||||
const trackedQueuedRequest = queuedRequest.finally(() => {
|
||||
queuedOverviewLoad = null
|
||||
})
|
||||
queuedOverviewLoad = trackedQueuedRequest
|
||||
}
|
||||
return queuedOverviewLoad
|
||||
}
|
||||
if (
|
||||
isReloadCooldownActive(this) ||
|
||||
(manualReload && !allowManualReload(this))
|
||||
manualReload &&
|
||||
(isReloadCooldownActive(this) || !allowManualReload(this))
|
||||
) {
|
||||
this.error = RELOAD_COOLDOWN_ERROR
|
||||
return false
|
||||
}
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>('banking:overview').finally(
|
||||
() => {
|
||||
const request = (async () => {
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
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 = ''
|
||||
return true
|
||||
}
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
})
|
||||
if (generation !== this.requestGeneration) return response.success
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
}
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
})()
|
||||
const trackedRequest = request.finally(() => {
|
||||
activeOverviewLoad = null
|
||||
})
|
||||
activeOverviewLoad = trackedRequest
|
||||
return trackedRequest
|
||||
},
|
||||
async perform(
|
||||
action: BankingAction,
|
||||
amount: number,
|
||||
phoneNumber?: string,
|
||||
): Promise<NuiResponse<BankingOverview>> {
|
||||
if (isReloadCooldownActive(this)) {
|
||||
this.error = RELOAD_COOLDOWN_ERROR
|
||||
return { error: RELOAD_COOLDOWN_ERROR, success: false }
|
||||
}
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
|
||||
@@ -167,6 +167,39 @@ describe('calls store', () => {
|
||||
expect(calls.contacts[0]?.favorite).toBe(true)
|
||||
})
|
||||
|
||||
it('sends a contact email and refreshes the contact list after saving', async () => {
|
||||
const savedContact = {
|
||||
email: 'alex.rivera@ifruit.com',
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
organization: 'Maze Bank',
|
||||
phone_number: '5551110001',
|
||||
}
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({ success: true, data: savedContact })
|
||||
.mockResolvedValueOnce({ success: true, data: [savedContact] })
|
||||
const calls = useCallsStore()
|
||||
|
||||
const response = await calls.saveContact({
|
||||
email: 'alex.rivera@ifruit.com',
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
organization: 'Maze Bank',
|
||||
phoneNumber: '5551110001',
|
||||
})
|
||||
|
||||
expect(response).toEqual({ success: true, data: savedContact })
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:save', {
|
||||
email: 'alex.rivera@ifruit.com',
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
organization: 'Maze Bank',
|
||||
phoneNumber: '5551110001',
|
||||
})
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
|
||||
expect(calls.contacts[0]?.email).toBe('alex.rivera@ifruit.com')
|
||||
})
|
||||
|
||||
it('keeps configured company branding on system contacts', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -189,8 +222,7 @@ describe('calls store', () => {
|
||||
await calls.loadContacts()
|
||||
|
||||
expect(calls.contacts[0]).toMatchObject({
|
||||
avatar_url:
|
||||
'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
organization: 'Los Santos Police Department',
|
||||
source: 'company',
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
|
||||
async function saveContact(contact: {
|
||||
avatarMediaId?: number | null
|
||||
email?: string
|
||||
id?: string
|
||||
name: string
|
||||
notes?: string
|
||||
|
||||
@@ -539,4 +539,28 @@ describe('companies store', () => {
|
||||
subject: 'Help needed',
|
||||
})
|
||||
})
|
||||
|
||||
it('dials through the service line with only the target number and returns the server call state', async () => {
|
||||
const call = {
|
||||
direction: 'outgoing' as const,
|
||||
id: 'company-call-1',
|
||||
otherNumber: '5551110001',
|
||||
speakerEnabled: false,
|
||||
speakerSupported: true,
|
||||
startedAt: 1_776_000_000,
|
||||
state: 'ringing' as const,
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({ data: call, success: true })
|
||||
const store = useCompaniesStore()
|
||||
|
||||
const response = await store.dialServiceLine(call.otherNumber)
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledOnce()
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('companies:dial-service-line', {
|
||||
phoneNumber: call.otherNumber,
|
||||
})
|
||||
expect(response).toEqual({ data: call, success: true })
|
||||
expect(store.mutating).toBe(false)
|
||||
expect(store.mutationError).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -524,6 +524,19 @@ export const useCompaniesStore = defineStore('companies', {
|
||||
: (response.error ?? 'request_failed')
|
||||
return response
|
||||
},
|
||||
async dialServiceLine(
|
||||
phoneNumber: string,
|
||||
): Promise<NuiResponse<PhoneCall>> {
|
||||
this.mutating = true
|
||||
const response = await nuiCall<PhoneCall>('companies:dial-service-line', {
|
||||
phoneNumber,
|
||||
})
|
||||
this.mutating = false
|
||||
this.mutationError = response.success
|
||||
? ''
|
||||
: (response.error ?? 'request_failed')
|
||||
return response
|
||||
},
|
||||
async mutateRequest(
|
||||
endpoint: string,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -58,6 +58,29 @@ describe('CrewLink store', () => {
|
||||
expect(store.error).toBe('invalid_code')
|
||||
})
|
||||
|
||||
it('sends only the password when logging in', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'invalid_credentials',
|
||||
})
|
||||
const store = useCrewLinkStore()
|
||||
await store.login('CrewLink123!')
|
||||
expect(nuiCall).toHaveBeenCalledWith('crewlink:login', {
|
||||
password: 'CrewLink123!',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends username, password, and avatar when registering', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({ success: true })
|
||||
const store = useCrewLinkStore()
|
||||
await store.register('Skyline', 'CrewLink123!', 42)
|
||||
expect(nuiCall).toHaveBeenCalledWith('crewlink:register', {
|
||||
avatarMediaId: 42,
|
||||
password: 'CrewLink123!',
|
||||
username: 'Skyline',
|
||||
})
|
||||
})
|
||||
|
||||
it('applies live members without replacing group metadata', async () => {
|
||||
const store = useCrewLinkStore()
|
||||
store.activeGroup = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
export const useCrewLinkStore = defineStore('crewlink', {
|
||||
state: () => ({
|
||||
activeGroup: null as CrewLinkGroup | null,
|
||||
authenticated: false,
|
||||
error: '',
|
||||
groups: [] as CrewLinkBootstrap['groups'],
|
||||
invitations: [] as CrewLinkBootstrap['invitations'],
|
||||
@@ -24,6 +25,7 @@ export const useCrewLinkStore = defineStore('crewlink', {
|
||||
}),
|
||||
actions: {
|
||||
applyBootstrap(data: CrewLinkBootstrap): void {
|
||||
this.authenticated = data.authenticated ?? Boolean(data.profile)
|
||||
this.profile = data.profile
|
||||
this.groups = data.groups ?? []
|
||||
this.activeGroup = data.activeGroup ?? null
|
||||
@@ -62,15 +64,28 @@ export const useCrewLinkStore = defineStore('crewlink', {
|
||||
}
|
||||
return response
|
||||
},
|
||||
createProfile(
|
||||
login(password: string): Promise<NuiResponse<CrewLinkBootstrap>> {
|
||||
return this.request('crewlink:login', { password })
|
||||
},
|
||||
register(
|
||||
username: string,
|
||||
password: string,
|
||||
avatarMediaId = 0,
|
||||
): Promise<NuiResponse<CrewLinkBootstrap>> {
|
||||
return this.request('crewlink:create-profile', {
|
||||
return this.request('crewlink:register', {
|
||||
avatarMediaId,
|
||||
password,
|
||||
username,
|
||||
})
|
||||
},
|
||||
logout(): Promise<NuiResponse> {
|
||||
this.authenticated = false
|
||||
this.profile = null
|
||||
this.groups = []
|
||||
this.activeGroup = null
|
||||
this.invitations = []
|
||||
return nuiCall('crewlink:logout')
|
||||
},
|
||||
updateProfile(
|
||||
username: string,
|
||||
mapVisible: boolean,
|
||||
|
||||
@@ -121,6 +121,34 @@ describe('flare store', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'no photos', photoMediaIds: [] },
|
||||
{ label: 'more than six photos', photoMediaIds: [1, 2, 3, 4, 5, 6, 7] },
|
||||
{ label: 'duplicate photos', photoMediaIds: [42, 42] },
|
||||
])(
|
||||
'rejects a profile with $label before calling NUI',
|
||||
async ({ photoMediaIds }) => {
|
||||
const draft: FlareProfileDraft = {
|
||||
age: bootstrap.profile!.age,
|
||||
avatar: bootstrap.profile!.avatar,
|
||||
bio: bootstrap.profile!.bio,
|
||||
gender: bootstrap.profile!.gender,
|
||||
interestedIn: bootstrap.profile!.interestedIn,
|
||||
interests: [...bootstrap.profile!.interests],
|
||||
lookingFor: bootstrap.profile!.lookingFor,
|
||||
maxAge: bootstrap.profile!.maxAge,
|
||||
minAge: bootstrap.profile!.minAge,
|
||||
name: bootstrap.profile!.name,
|
||||
photoMediaIds,
|
||||
}
|
||||
const flare = useFlareStore()
|
||||
|
||||
expect(await flare.saveProfile(draft)).toBe(false)
|
||||
expect(flare.error).toBe('invalid_profile_photos')
|
||||
expect(mockNuiCall).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('uses a real Super Like and removes the target from both decks', async () => {
|
||||
const match: FlareMatch = {
|
||||
id: 'match-1',
|
||||
|
||||
@@ -10,6 +10,18 @@ import type {
|
||||
} from '@/types/flare'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
function hasValidProfilePhotos(
|
||||
photoMediaIds: unknown,
|
||||
): photoMediaIds is number[] {
|
||||
return (
|
||||
Array.isArray(photoMediaIds) &&
|
||||
photoMediaIds.length >= 1 &&
|
||||
photoMediaIds.length <= 6 &&
|
||||
new Set(photoMediaIds).size === photoMediaIds.length &&
|
||||
photoMediaIds.every((mediaId) => Number.isInteger(mediaId) && mediaId > 0)
|
||||
)
|
||||
}
|
||||
|
||||
export const useFlareStore = defineStore('flare', {
|
||||
state: () => ({
|
||||
activeMatchId: '' as string,
|
||||
@@ -54,6 +66,10 @@ export const useFlareStore = defineStore('flare', {
|
||||
return response.success
|
||||
},
|
||||
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
|
||||
if (!hasValidProfilePhotos(draft.photoMediaIds)) {
|
||||
this.error = 'invalid_profile_photos'
|
||||
return false
|
||||
}
|
||||
const response = await nuiCall<FlareBootstrap>(
|
||||
'flare:save-profile',
|
||||
draft,
|
||||
|
||||
@@ -35,10 +35,14 @@ describe('messages store', () => {
|
||||
})
|
||||
|
||||
it('shows an outgoing message immediately and marks it delivered', async () => {
|
||||
let resolveSend: ((value: { data: SmsMessage; success: true }) => void) | undefined
|
||||
const response = new Promise<{ data: SmsMessage; success: true }>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
let resolveSend:
|
||||
| ((value: { data: SmsMessage; success: true }) => void)
|
||||
| undefined
|
||||
const response = new Promise<{ data: SmsMessage; success: true }>(
|
||||
(resolve) => {
|
||||
resolveSend = resolve
|
||||
},
|
||||
)
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
@@ -78,6 +82,64 @@ describe('messages store', () => {
|
||||
expect(messages.messages[0].delivery_status).toBe('failed')
|
||||
})
|
||||
|
||||
it('discards a failed optimistic attachment when its preview remains retryable', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ error: 'request_failed', success: false })
|
||||
|
||||
const messages = useMessagesStore()
|
||||
await messages.openThread('4205550196')
|
||||
await messages.send(
|
||||
{
|
||||
body: 'Retry this photo',
|
||||
mediaAssetId: '17',
|
||||
messageType: 'image',
|
||||
},
|
||||
{ discardFailedOptimistic: true },
|
||||
)
|
||||
|
||||
expect(messages.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a photo caption on the optimistic media message', async () => {
|
||||
const serverMessage: SmsMessage = {
|
||||
...sentMessage('photo-server-id'),
|
||||
body: 'Look at this',
|
||||
media_asset_id: 'https://media.example/photo.jpg',
|
||||
media_mime: 'image/jpeg',
|
||||
message_type: 'image',
|
||||
}
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: serverMessage, success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
|
||||
const messages = useMessagesStore()
|
||||
await messages.openThread('4205550196')
|
||||
const sending = messages.send({
|
||||
body: ' Look at this ',
|
||||
mediaAssetId: '17',
|
||||
messageType: 'image',
|
||||
})
|
||||
|
||||
expect(messages.messages[0]).toMatchObject({
|
||||
body: 'Look at this',
|
||||
delivery_status: 'sending',
|
||||
media_asset_id: '17',
|
||||
message_type: 'image',
|
||||
})
|
||||
|
||||
await sending
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'messages:send', {
|
||||
body: ' Look at this ',
|
||||
mediaAssetId: '17',
|
||||
messageType: 'image',
|
||||
phoneNumber: '4205550196',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a shared contact immediately and sends only its id', async () => {
|
||||
const contact = {
|
||||
avatar_url: 'https://picsum.photos/seed/shared-alex/240/240',
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import { sortConversationsByRecency } from '@/utils/messages'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
type SendOptions = {
|
||||
discardFailedOptimistic?: boolean
|
||||
}
|
||||
|
||||
export const useMessagesStore = defineStore('messages', () => {
|
||||
const conversations = ref<SmsConversation[]>([])
|
||||
const messages = ref<SmsMessage[]>([])
|
||||
@@ -30,8 +34,7 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
phoneNumber: String(conversation.phoneNumber),
|
||||
})),
|
||||
)
|
||||
}
|
||||
else if (!response.success) conversations.value = []
|
||||
} else if (!response.success) conversations.value = []
|
||||
return response.success
|
||||
}
|
||||
|
||||
@@ -45,8 +48,7 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
activeNumber.value = phoneNumber
|
||||
messages.value = response.data.map((message) => ({
|
||||
...message,
|
||||
delivery_status:
|
||||
message.direction === 'sent' ? 'delivered' : undefined,
|
||||
delivery_status: message.direction === 'sent' ? 'delivered' : undefined,
|
||||
recipient_number: String(message.recipient_number),
|
||||
sender_number: String(message.sender_number),
|
||||
}))
|
||||
@@ -56,13 +58,18 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
|
||||
async function send(
|
||||
outgoing: SmsOutgoingMessage,
|
||||
options: SendOptions = {},
|
||||
): Promise<NuiResponse<SmsMessage>> {
|
||||
if (!activeNumber.value) return { success: false, error: 'invalid_number' }
|
||||
const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const optimistic: SmsMessage = {
|
||||
body:
|
||||
outgoing.messageType === 'text' || outgoing.messageType === 'share'
|
||||
? outgoing.body?.trim() ?? ''
|
||||
outgoing.messageType === 'text' ||
|
||||
outgoing.messageType === 'share' ||
|
||||
outgoing.messageType === 'image' ||
|
||||
outgoing.messageType === 'gif' ||
|
||||
outgoing.messageType === 'video'
|
||||
? (outgoing.body?.trim() ?? '')
|
||||
: '',
|
||||
client_id: clientId,
|
||||
contact: outgoing.messageType === 'contact' ? outgoing.contact : null,
|
||||
@@ -78,10 +85,9 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
: null,
|
||||
media_duration_ms:
|
||||
outgoing.messageType === 'voice' || outgoing.messageType === 'video'
|
||||
? outgoing.mediaDurationMs ?? null
|
||||
? (outgoing.mediaDurationMs ?? null)
|
||||
: null,
|
||||
media_mime:
|
||||
outgoing.messageType === 'voice' ? outgoing.mediaMime : null,
|
||||
media_mime: outgoing.messageType === 'voice' ? outgoing.mediaMime : null,
|
||||
media_waveform:
|
||||
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
|
||||
message_type: outgoing.messageType,
|
||||
@@ -113,7 +119,14 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
(message) => message.client_id === clientId,
|
||||
)
|
||||
if (!response.success || !response.data) {
|
||||
if (index >= 0) messages.value[index].delivery_status = 'failed'
|
||||
if (index >= 0) {
|
||||
if (options.discardFailedOptimistic) {
|
||||
messages.value.splice(index, 1)
|
||||
delete mediaSources.value[clientId]
|
||||
} else {
|
||||
messages.value[index].delivery_status = 'failed'
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -55,4 +55,20 @@ describe('phone locale fallback', () => {
|
||||
'Character name',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Messages media controls translated with a partial server locale', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({ locales: { Apps: { messages: { name: 'Messages' } } } })
|
||||
|
||||
expect(phone.t('Apps.messages.attachmentPreview')).toBe(
|
||||
'Selected attachments',
|
||||
)
|
||||
expect(phone.t('Apps.messages.attachmentLimit', { count: '6' })).toBe(
|
||||
'You can attach up to 6 photos or videos.',
|
||||
)
|
||||
expect(phone.t('Apps.messages.removeAttachment', { number: '2' })).toBe(
|
||||
'Remove attachment 2',
|
||||
)
|
||||
expect(phone.t('Apps.messages.seekAudio')).toBe('Seek Audio')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,6 +217,13 @@ const companiesFallbackLocales = {
|
||||
publicAvailability: 'Public Availability',
|
||||
takeCalls: 'Take company calls',
|
||||
takeCallsBody: 'Route new service-line calls to this active SIM.',
|
||||
dialServiceLine: 'Call from service line',
|
||||
dialServiceLineBody: 'Make an outgoing call that displays {number}.',
|
||||
dialServiceLineHint:
|
||||
'The recipient will see {number} as the incoming caller.',
|
||||
targetNumber: 'Phone number',
|
||||
targetNumberHint: 'Enter a phone number',
|
||||
callNow: 'Call Now',
|
||||
overview: 'Today at a Glance',
|
||||
metrics: {
|
||||
new: 'New',
|
||||
@@ -634,10 +641,12 @@ const defaultLocales: LocaleTree = {
|
||||
authEyebrow: 'Private crew network',
|
||||
authTitle: 'Welcome to CrewLink',
|
||||
authBody:
|
||||
'Your iFruit email is linked automatically. Use your CrewLink username to continue.',
|
||||
'Your iFruit email is linked automatically. Enter your password to continue.',
|
||||
login: 'Log in',
|
||||
register: 'Register',
|
||||
ifruitEmail: 'iFruit address',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: '8–72 characters',
|
||||
gallery: 'Photos',
|
||||
camera: 'Camera',
|
||||
backToLogin: 'Back to CrewLink login',
|
||||
@@ -645,6 +654,8 @@ const defaultLocales: LocaleTree = {
|
||||
no_ifruit_account:
|
||||
'Sign in to your Sky Cloud account in Settings first.',
|
||||
invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
invalid_credentials: 'The password for this iFruit email is incorrect.',
|
||||
profile_not_found: 'No CrewLink profile exists for this iFruit email.',
|
||||
profile_exists: 'This iFruit email already has a CrewLink profile.',
|
||||
username_taken: 'That CrewLink username is already taken.',
|
||||
@@ -860,7 +871,8 @@ const defaultLocales: LocaleTree = {
|
||||
photo: 'Profile photo',
|
||||
profilePhotos: 'Profile photos',
|
||||
profilePhotosBody:
|
||||
'Add up to six photos from Photos or Camera. Your first photo is shown first.',
|
||||
'Add one to six photos from Photos or Camera. Your first photo is shown first.',
|
||||
profilePhotoRequired: 'Add at least one profile photo to continue.',
|
||||
addPhotos: 'Add photos',
|
||||
choosePhotos: 'Choose from Photos',
|
||||
primaryPhoto: 'Main',
|
||||
@@ -999,7 +1011,7 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_profile: 'Check your name, age and profile text.',
|
||||
profile_not_found: 'This Flare account is no longer available.',
|
||||
invalid_profile_photos:
|
||||
'Choose or take up to six photos saved in your own Photos library.',
|
||||
'Choose or take at least one and up to six photos saved in your own Photos library.',
|
||||
request_failed: 'Flare could not save those changes. Try again.',
|
||||
invalid_target: 'This profile is no longer available.',
|
||||
invalid_choice: 'That swipe could not be saved.',
|
||||
@@ -1399,6 +1411,17 @@ const defaultLocales: LocaleTree = {
|
||||
verified: 'Verified profile',
|
||||
noBio: 'No bio yet.',
|
||||
showMore: 'Show more',
|
||||
moreActions: 'More actions',
|
||||
now: 'now',
|
||||
minutesShort: '{count}m',
|
||||
hoursShort: '{count}h',
|
||||
daysShort: '{count}d',
|
||||
comments: 'Comments',
|
||||
noComments: 'No comments yet',
|
||||
addComment: 'Add a comment...',
|
||||
postComment: 'Post comment',
|
||||
likeComment: 'Like comment by {name}',
|
||||
likesCount: '{count} likes',
|
||||
replies: 'Replies',
|
||||
noReplies: 'No replies yet. Start the conversation.',
|
||||
likes: 'Likes',
|
||||
@@ -1834,12 +1857,29 @@ const defaultLocales: LocaleTree = {
|
||||
loadMore: 'Load More',
|
||||
retryGifs: 'Try Again',
|
||||
moreActions: 'More Actions',
|
||||
inboxActions: 'Conversation Actions',
|
||||
sortLabel: 'Conversation Sort',
|
||||
sortNewest: 'Newest First',
|
||||
sortOldest: 'Oldest First',
|
||||
unreadConversation: '{name}, {count} unread messages',
|
||||
attachmentPreview: 'Selected attachments',
|
||||
attachmentLimit: 'You can attach up to {count} photos or videos.',
|
||||
removeAttachment: 'Remove attachment {number}',
|
||||
contactDetails: 'Contact Details',
|
||||
contactName: 'Name',
|
||||
phoneNumber: 'Phone Number',
|
||||
company: 'Company',
|
||||
contactActions: 'Contact Actions',
|
||||
call: 'Call',
|
||||
messageAction: 'Message',
|
||||
addContact: 'Add Contact',
|
||||
showInContacts: 'Show in Contacts',
|
||||
blockContact: 'Block Contact',
|
||||
blockContactTitle: 'Block this contact?',
|
||||
blockContactBody:
|
||||
'{name} will no longer be able to call or message this SIM.',
|
||||
blockContactFailed: 'The contact could not be blocked.',
|
||||
contactBlocked: 'Contact blocked.',
|
||||
deleteContact: 'Delete Contact',
|
||||
selectedCount: '{count} Selected',
|
||||
deleteSelected: 'Delete',
|
||||
@@ -1851,6 +1891,7 @@ const defaultLocales: LocaleTree = {
|
||||
recordVoice: 'Record Audio',
|
||||
playAudio: 'Play Audio',
|
||||
pauseAudio: 'Pause Audio',
|
||||
seekAudio: 'Seek Audio',
|
||||
recording: 'Recording',
|
||||
stopAndSend: 'Stop and Send',
|
||||
cancelRecording: 'Cancel Recording',
|
||||
@@ -1887,6 +1928,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.',
|
||||
blocked: 'This contact has blocked calls and messages from your SIM.',
|
||||
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.',
|
||||
@@ -2265,6 +2307,14 @@ const defaultLocales: LocaleTree = {
|
||||
noContacts: 'No contacts saved yet.',
|
||||
amount: 'Amount',
|
||||
amountPlaceholder: 'Enter an amount',
|
||||
transactionDetails: 'Transaction details',
|
||||
transactionDate: 'Date',
|
||||
transactionDirection: 'Direction',
|
||||
transactionReference: 'Reference',
|
||||
notifications: {
|
||||
receivedTitle: 'Money received',
|
||||
received: 'You received {amount} from {sender}.',
|
||||
},
|
||||
transactions: {
|
||||
deposit: 'Cash deposit',
|
||||
withdrawal: 'Cash withdrawal',
|
||||
@@ -3620,6 +3670,7 @@ const defaultLocales: LocaleTree = {
|
||||
minutesShort: 'min',
|
||||
seconds: 'Seconds',
|
||||
secondsShort: 'sec',
|
||||
description: 'Description',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Timer',
|
||||
sound: 'Sound',
|
||||
@@ -4028,6 +4079,8 @@ const defaultLocales: LocaleTree = {
|
||||
import_url_unavailable: 'The linked media could not be reached.',
|
||||
import_size_unavailable: 'The website did not provide the media size.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
profile_photo_required:
|
||||
'This is the last photo on your Flare profile. Add another profile photo before deleting it.',
|
||||
operation_in_progress:
|
||||
'Another media operation is already in progress.',
|
||||
owner_changed: 'The active phone account changed.',
|
||||
@@ -4200,6 +4253,8 @@ const defaultLocales: LocaleTree = {
|
||||
wallpaperFromPhotosDescription: 'Use one of your photos',
|
||||
wallpaperFromCamera: 'Take Photo',
|
||||
wallpaperFromCameraDescription: 'Create a new wallpaper',
|
||||
wallpaperCustomUpload: 'Upload Custom Image',
|
||||
wallpaperCustomUploadDescription: 'Use a verified HTTPS image link',
|
||||
wallpaperHistory: 'Recent',
|
||||
wallpaperCustom: 'Photo wallpaper',
|
||||
wallpaperTarget: 'Wallpaper destination',
|
||||
@@ -4487,6 +4542,15 @@ const defaultLocales: LocaleTree = {
|
||||
rateLimited: 'Too many attempts. Please wait.',
|
||||
},
|
||||
},
|
||||
HardwareButtons: {
|
||||
lock: 'Lock phone',
|
||||
mute: 'Mute ringtone and notifications',
|
||||
unlock: 'Unlock phone',
|
||||
unmute: 'Unmute ringtone and notifications',
|
||||
volumeDown: 'Volume down',
|
||||
volumeLevel: 'Volume {value} percent',
|
||||
volumeUp: 'Volume up',
|
||||
},
|
||||
Home: {
|
||||
appLibrary: 'App Library',
|
||||
appLibrarySearch: 'Search apps',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import type {
|
||||
WidgetKind,
|
||||
WidgetLayout,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
addWidget,
|
||||
createDefaultWidgetLayout,
|
||||
@@ -18,6 +23,12 @@ export const useWidgetsStore = defineStore('widgets', {
|
||||
layout: createDefaultWidgetLayout(),
|
||||
}),
|
||||
actions: {
|
||||
applyLayout(layout: WidgetLayout): boolean {
|
||||
if (layout === this.layout) return false
|
||||
this.layout = layout
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
add(kind: WidgetKind, size: WidgetSize, page: number): string | null {
|
||||
const previousIds = new Set(
|
||||
this.layout.instances.map((instance) => instance.id),
|
||||
|
||||
Reference in New Issue
Block a user