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:
Type
2026-08-17 11:38:13 +02:00
152 changed files with 15805 additions and 4315 deletions
+55 -8
View File
@@ -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 -2
View File
@@ -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,
+56 -9
View File
@@ -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)
})
})
+43 -22
View File
@@ -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
+34 -2
View File
@@ -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',
})
+1
View File
@@ -36,6 +36,7 @@ export const useCallsStore = defineStore('calls', () => {
async function saveContact(contact: {
avatarMediaId?: number | null
email?: string
id?: string
name: string
notes?: string
+24
View File
@@ -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('')
})
})
+13
View File
@@ -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>,
+23
View File
@@ -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 = {
+17 -2
View File
@@ -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,
+28
View File
@@ -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',
+16
View File
@@ -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,
+66 -4
View File
@@ -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',
+23 -10
View File
@@ -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
}
+16
View File
@@ -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')
})
})
+67 -3
View File
@@ -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: '872 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 320 letters, numbers, dots, or underscores.',
invalid_password: 'Password must be 872 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',
+12 -1
View File
@@ -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),