mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge branch 'dev' into feature/funk
# Conflicts: # frontend/src/config/apps.test.ts # frontend/src/config/apps.ts # frontend/src/stores/phone.ts # frontend/testserver/index.cjs # sky_phone/config/locales/en.lua # sky_phone/source/html/index.html # sky_phone/source/server/db_migrate.lua
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { removeHomeApp } from '@/utils/homeLayout'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
@@ -32,6 +34,7 @@ describe('app store', () => {
|
||||
expect(apps.launchCounts).toEqual({ mail: 4 })
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: { mail: 4 },
|
||||
})
|
||||
})
|
||||
@@ -53,6 +56,7 @@ describe('app store', () => {
|
||||
expect(apps.claimedApps).toEqual(['snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
@@ -69,4 +73,84 @@ describe('app store', () => {
|
||||
expect(apps.claimedApps).toEqual(['memory', 'snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reinstalls core and claimed apps removed from the Home Screen', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['memory'] })
|
||||
apps.removeHomeApp('notes')
|
||||
apps.removeHomeApp('memory')
|
||||
mocks.saveDeviceNamespace.mockClear()
|
||||
|
||||
apps.installApp('notes')
|
||||
apps.installApp('memory')
|
||||
|
||||
expect(apps.installingApps).toEqual({ notes: true, memory: true })
|
||||
expect(apps.homeLayout.hidden).toEqual(['notes', 'memory'])
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.installingApps).toEqual({})
|
||||
expect(apps.homeLayout.hidden).toEqual([])
|
||||
expect(apps.homeLayout.grid).toContain('notes')
|
||||
expect(apps.homeLayout.grid).toContain('memory')
|
||||
expect(apps.claimedApps).toEqual(['memory'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('prevents protected apps from being removed from the Home Screen', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
|
||||
'app-store',
|
||||
'settings',
|
||||
'camera',
|
||||
'photos',
|
||||
'phone',
|
||||
'messages',
|
||||
'mail',
|
||||
])
|
||||
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
|
||||
apps.removeHomeApp(appId)
|
||||
expect(apps.homeLayout.hidden).not.toContain(appId)
|
||||
}
|
||||
|
||||
expect(mocks.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores protected apps hidden by older persisted layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
const legacyLayout = removeHomeApp(apps.homeLayout, 'mail')
|
||||
|
||||
apps.hydrate({ homeLayout: legacyLayout })
|
||||
|
||||
expect(apps.homeLayout.hidden).not.toContain('mail')
|
||||
expect(apps.homeLayout.grid).toContain('mail')
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('persists home reordering and removal independently from installation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
apps.moveHomeApp('grid', notesIndex, 'grid', 0)
|
||||
expect(apps.homeLayout.grid[0]).toBe('notes')
|
||||
|
||||
apps.removeHomeApp('notes')
|
||||
expect(apps.homeLayout.grid).not.toContain('notes')
|
||||
expect(apps.homeLayout.hidden).toContain('notes')
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import {
|
||||
isPhoneAppId,
|
||||
NON_REMOVABLE_PHONE_APP_IDS,
|
||||
PHONE_APPS,
|
||||
} from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
createDefaultHomeLayout,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeArea,
|
||||
} from '@/utils/homeLayout'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
const DEFAULT_GRID_IDS = [...PHONE_APPS]
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
const DEFAULT_DOCK_IDS = PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
const CORE_APP_IDS = PHONE_APPS.filter((app) => app.category !== 'games').map(
|
||||
(app) => app.id,
|
||||
)
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
CORE_APP_IDS,
|
||||
DEFAULT_GRID_IDS,
|
||||
DEFAULT_DOCK_IDS,
|
||||
),
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
@@ -16,21 +42,34 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, id)
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (this.claimedApps.includes(id) || this.installingApps[id]) return
|
||||
const installed =
|
||||
CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
(installed && !this.homeLayout.hidden.includes(id))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.installingApps[id] = true
|
||||
globalThis.setTimeout(() => {
|
||||
this.claimApp(id)
|
||||
if (CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)) {
|
||||
this.restoreHomeApp(id)
|
||||
} else {
|
||||
this.claimApp(id)
|
||||
}
|
||||
delete this.installingApps[id]
|
||||
}, INSTALL_DURATION_MS)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
launchCounts?: unknown
|
||||
} | null
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
@@ -39,6 +78,23 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const installedIds = [...CORE_APP_IDS, ...this.claimedApps]
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
DEFAULT_GRID_IDS,
|
||||
DEFAULT_DOCK_IDS,
|
||||
)
|
||||
this.homeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((id) =>
|
||||
NON_REMOVABLE_PHONE_APP_IDS.has(id),
|
||||
)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
this.installingApps = {}
|
||||
this.launchCounts = {}
|
||||
if (data?.launchCounts && typeof data.launchCounts === 'object') {
|
||||
@@ -53,14 +109,41 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (protectedHiddenAppIds.length) this.persist()
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
moveHomeApp(
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): void {
|
||||
this.homeLayout = moveHomeApp(
|
||||
this.homeLayout,
|
||||
from,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
this.persist()
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (NON_REMOVABLE_PHONE_APP_IDS.has(appId)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('apps', {
|
||||
claimedApps: this.claimedApps,
|
||||
homeLayout: this.homeLayout,
|
||||
launchCounts: this.launchCounts,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import type { BankingOverview } from '@/types/banking'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
const overview: BankingOverview = {
|
||||
bank: 24787,
|
||||
cash: 2350,
|
||||
currency: '$',
|
||||
playerId: 42,
|
||||
playerName: 'Alex Morgan',
|
||||
transactions: [],
|
||||
}
|
||||
|
||||
describe('banking store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads the server-authoritative banking overview', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: overview, success: true })
|
||||
const banking = useBankingStore()
|
||||
|
||||
expect(await banking.load()).toBe(true)
|
||||
expect(banking.overview).toEqual(overview)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('banking:overview')
|
||||
})
|
||||
|
||||
it('updates balances after a successful transfer', async () => {
|
||||
const updated = { ...overview, bank: 23787 }
|
||||
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
|
||||
const banking = useBankingStore()
|
||||
|
||||
const response = await banking.perform('transfer', 1000, 17)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(banking.overview?.bank).toBe(23787)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('banking:transfer', {
|
||||
amount: 1000,
|
||||
target: 17,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the previous overview and exposes server errors', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'insufficient_funds',
|
||||
success: false,
|
||||
})
|
||||
const banking = useBankingStore()
|
||||
banking.overview = overview
|
||||
|
||||
await banking.perform('transfer', 50000, 17)
|
||||
|
||||
expect(banking.overview).toEqual(overview)
|
||||
expect(banking.error).toBe('insufficient_funds')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { BankingAction, BankingOverview } from '@/types/banking'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useBankingStore = defineStore('banking', {
|
||||
state: () => ({
|
||||
error: '',
|
||||
isLoading: false,
|
||||
overview: null as BankingOverview | null,
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>('banking:overview')
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
}
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
},
|
||||
async perform(
|
||||
action: BankingAction,
|
||||
amount: number,
|
||||
target?: number,
|
||||
): Promise<NuiResponse<BankingOverview>> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
|
||||
amount,
|
||||
...(target === undefined ? {} : { target }),
|
||||
})
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import type { DarkChatMessage, DarkChatThread } from '@/types/darkchat'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
const conversation: DarkChatThread['conversation'] = {
|
||||
blockedByPeer: false,
|
||||
createdAt: '2026-08-06 20:00:00',
|
||||
disappearingSeconds: 3600,
|
||||
id: 'conversation-id',
|
||||
notificationsEnabled: true,
|
||||
peer: { alias: 'Nova', avatarSeed: 42, darkId: 'dark:N0VA-41KQ', id: 2 },
|
||||
readReceipts: true,
|
||||
}
|
||||
|
||||
function message(id: string): DarkChatMessage {
|
||||
return {
|
||||
body: 'Quiet channel',
|
||||
conversationId: conversation.id,
|
||||
createdAt: '2026-08-06 20:01:00',
|
||||
direction: 'sent',
|
||||
id,
|
||||
messageType: 'text',
|
||||
reactions: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('darkchat store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('optimistically sends and confirms a private message', async () => {
|
||||
let resolveSend: ((value: { data: DarkChatMessage; success: true }) => void) | undefined
|
||||
const pending = new Promise<{ data: DarkChatMessage; success: true }>((resolve) => (resolveSend = resolve))
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
.mockImplementationOnce(() => pending)
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
|
||||
const store = useDarkChatStore()
|
||||
await store.openThread(conversation.id)
|
||||
const sending = store.send({ body: 'Quiet channel', messageType: 'text' })
|
||||
expect(store.messages[0]).toMatchObject({ deliveryStatus: 'sending', body: 'Quiet channel' })
|
||||
|
||||
resolveSend?.({ data: message('server-message'), success: true })
|
||||
await sending
|
||||
expect(store.messages[0]).toMatchObject({ deliveryStatus: 'delivered', id: 'server-message' })
|
||||
})
|
||||
|
||||
it('keeps failed messages visible for delivery feedback', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
.mockResolvedValueOnce({ error: 'blocked', success: false })
|
||||
|
||||
const store = useDarkChatStore()
|
||||
await store.openThread(conversation.id)
|
||||
await store.send({ body: 'Quiet channel', messageType: 'text' })
|
||||
expect(store.messages[0].deliveryStatus).toBe('failed')
|
||||
})
|
||||
|
||||
it('uses a local media preview without sending that URL to the server', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
.mockResolvedValueOnce({ data: { ...message('media-id'), messageType: 'image' }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
|
||||
const store = useDarkChatStore()
|
||||
await store.openThread(conversation.id)
|
||||
const sending = store.send({
|
||||
mediaAssetId: '17',
|
||||
mediaPreviewUrl: 'https://media.example/photo.jpg',
|
||||
messageType: 'image',
|
||||
})
|
||||
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
mediaPayload: 'https://media.example/photo.jpg',
|
||||
messageType: 'image',
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'darkchat:send', {
|
||||
conversationId: conversation.id,
|
||||
mediaAssetId: '17',
|
||||
messageType: 'image',
|
||||
})
|
||||
await sending
|
||||
})
|
||||
|
||||
it('loads protected voice data once', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' },
|
||||
success: true,
|
||||
})
|
||||
const store = useDarkChatStore()
|
||||
expect(await store.loadMedia('voice-id')).toBe(true)
|
||||
expect(await store.loadMedia('voice-id')).toBe(true)
|
||||
expect(store.mediaSources['voice-id']).toBe('data:audio/webm;codecs=opus;base64,ZmFrZQ==')
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
DarkChatBootstrap,
|
||||
DarkChatContact,
|
||||
DarkChatConversation,
|
||||
DarkChatConversationSummary,
|
||||
DarkChatMessage,
|
||||
DarkChatOutgoing,
|
||||
DarkChatProfile,
|
||||
DarkChatThread,
|
||||
} from '@/types/darkchat'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useDarkChatStore = defineStore('darkchat', () => {
|
||||
const profile = ref<DarkChatProfile | null>(null)
|
||||
const contacts = ref<DarkChatContact[]>([])
|
||||
const conversations = ref<DarkChatConversationSummary[]>([])
|
||||
const activeConversation = ref<DarkChatConversation | null>(null)
|
||||
const messages = ref<DarkChatMessage[]>([])
|
||||
const mediaSources = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const unreadCount = computed(() =>
|
||||
conversations.value.reduce((total, conversation) => total + conversation.unread, 0),
|
||||
)
|
||||
|
||||
async function bootstrap(): Promise<boolean> {
|
||||
loading.value = true
|
||||
const response = await nuiCall<DarkChatBootstrap>('darkchat:bootstrap')
|
||||
loading.value = false
|
||||
lastError.value = response.error ?? null
|
||||
if (!response.success || !response.data) {
|
||||
profile.value = null
|
||||
contacts.value = []
|
||||
conversations.value = []
|
||||
return false
|
||||
}
|
||||
profile.value = response.data.profile
|
||||
contacts.value = response.data.contacts
|
||||
conversations.value = response.data.conversations
|
||||
return true
|
||||
}
|
||||
|
||||
async function openThread(conversationId: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
const response = await nuiCall<DarkChatThread>('darkchat:thread', { conversationId })
|
||||
loading.value = false
|
||||
lastError.value = response.error ?? null
|
||||
if (!response.success || !response.data) return false
|
||||
activeConversation.value = response.data.conversation
|
||||
messages.value = response.data.messages.map((message) => ({
|
||||
...message,
|
||||
deliveryStatus: message.direction === 'sent' ? 'delivered' : undefined,
|
||||
}))
|
||||
await refreshInbox()
|
||||
return true
|
||||
}
|
||||
|
||||
async function refreshInbox(): Promise<boolean> {
|
||||
const response = await nuiCall<DarkChatBootstrap>('darkchat:bootstrap')
|
||||
if (!response.success || !response.data) return false
|
||||
profile.value = response.data.profile
|
||||
contacts.value = response.data.contacts
|
||||
conversations.value = response.data.conversations
|
||||
return true
|
||||
}
|
||||
|
||||
async function start(identifier: string): Promise<NuiResponse<{ conversationId: string }>> {
|
||||
const response = await nuiCall<{ conversationId: string }>('darkchat:start', { identifier })
|
||||
if (response.success) await refreshInbox()
|
||||
return response
|
||||
}
|
||||
|
||||
async function send(outgoing: DarkChatOutgoing): Promise<NuiResponse<DarkChatMessage>> {
|
||||
if (!activeConversation.value) return { success: false, error: 'invalid_conversation' }
|
||||
const clientId = `dark-pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const { mediaPreviewUrl, ...request } = outgoing
|
||||
const optimistic: DarkChatMessage = {
|
||||
body: outgoing.body ?? '',
|
||||
clientId,
|
||||
conversationId: activeConversation.value.id,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
deliveryStatus: 'sending',
|
||||
direction: 'sent',
|
||||
id: clientId,
|
||||
mediaDurationMs: outgoing.mediaDurationMs,
|
||||
mediaMime: outgoing.mediaMime,
|
||||
mediaPayload:
|
||||
outgoing.messageType === 'image' || outgoing.messageType === 'video'
|
||||
? mediaPreviewUrl
|
||||
: outgoing.mediaPayload,
|
||||
mediaWaveform: outgoing.mediaWaveform,
|
||||
messageType: outgoing.messageType,
|
||||
reactions: {},
|
||||
replyToId: outgoing.replyToId,
|
||||
}
|
||||
messages.value.push(optimistic)
|
||||
if (outgoing.messageType === 'voice' && outgoing.mediaPayload) {
|
||||
mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
|
||||
}
|
||||
const response = await nuiCall<DarkChatMessage>('darkchat:send', {
|
||||
...request,
|
||||
conversationId: activeConversation.value.id,
|
||||
})
|
||||
const index = messages.value.findIndex((message) => message.clientId === clientId)
|
||||
if (!response.success || !response.data) {
|
||||
if (index >= 0) messages.value[index].deliveryStatus = 'failed'
|
||||
return response
|
||||
}
|
||||
const source = mediaSources.value[clientId]
|
||||
delete mediaSources.value[clientId]
|
||||
if (source) mediaSources.value[response.data.id] = source
|
||||
if (index >= 0) {
|
||||
messages.value[index] = { ...response.data, clientId, deliveryStatus: 'delivered' }
|
||||
}
|
||||
await refreshInbox()
|
||||
return response
|
||||
}
|
||||
|
||||
async function loadMedia(messageId: string): Promise<boolean> {
|
||||
if (mediaSources.value[messageId]) return true
|
||||
const response = await nuiCall<{ mime: string; payload: string }>('darkchat:media', { messageId })
|
||||
if (!response.success || !response.data) return false
|
||||
mediaSources.value[messageId] = `data:${response.data.mime};base64,${response.data.payload}`
|
||||
return true
|
||||
}
|
||||
|
||||
async function mutate(endpoint: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
const response = await nuiCall(`darkchat:${endpoint}`, data)
|
||||
return response.success
|
||||
}
|
||||
|
||||
function closeThread(): void {
|
||||
activeConversation.value = null
|
||||
messages.value = []
|
||||
mediaSources.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
activeConversation,
|
||||
bootstrap,
|
||||
closeThread,
|
||||
contacts,
|
||||
conversations,
|
||||
lastError,
|
||||
loadMedia,
|
||||
loading,
|
||||
mediaSources,
|
||||
messages,
|
||||
mutate,
|
||||
openThread,
|
||||
profile,
|
||||
refreshInbox,
|
||||
send,
|
||||
start,
|
||||
unreadCount,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import type { PhoneMedia } from '@/types/media'
|
||||
|
||||
const photo: PhoneMedia = {
|
||||
createdAt: 1_786_035_600,
|
||||
id: 17,
|
||||
mediaType: 'photo',
|
||||
url: 'https://media.example/photo.jpg',
|
||||
}
|
||||
|
||||
describe('message media handoff', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('returns captured media to the requesting DarkChat conversation', () => {
|
||||
const store = useMessageMediaStore()
|
||||
store.begin('darkchat:conversation-id', 'photo', '/apps/darkchat')
|
||||
|
||||
expect(store.complete(photo)).toBe('/apps/darkchat')
|
||||
expect(store.consume('darkchat:another-conversation')).toBeNull()
|
||||
expect(store.consume('darkchat:conversation-id')).toEqual(photo)
|
||||
})
|
||||
|
||||
it('keeps the request active when the selected media type does not match', () => {
|
||||
const store = useMessageMediaStore()
|
||||
store.begin('4205550196', 'video')
|
||||
|
||||
expect(store.complete(photo)).toBeNull()
|
||||
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
|
||||
expect(store.cancel()).toBe('/apps/messages')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,8 @@ import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
|
||||
type MessageMediaRequest = {
|
||||
mediaType: MediaType
|
||||
phoneNumber: string
|
||||
returnPath: string
|
||||
target: string
|
||||
}
|
||||
|
||||
type MessageMediaResult = MessageMediaRequest & {
|
||||
@@ -17,20 +18,28 @@ export const useMessageMediaStore = defineStore('message-media', {
|
||||
result: null as MessageMediaResult | null,
|
||||
}),
|
||||
actions: {
|
||||
begin(phoneNumber: string, mediaType: MediaType): void {
|
||||
this.request = { mediaType, phoneNumber }
|
||||
begin(
|
||||
target: string,
|
||||
mediaType: MediaType,
|
||||
returnPath = '/apps/messages',
|
||||
): void {
|
||||
this.request = { mediaType, returnPath, target }
|
||||
this.result = null
|
||||
},
|
||||
cancel(): void {
|
||||
cancel(): string {
|
||||
const returnPath = this.request?.returnPath ?? '/apps/messages'
|
||||
this.request = null
|
||||
return returnPath
|
||||
},
|
||||
complete(media: PhoneMedia): void {
|
||||
if (!this.request || this.request.mediaType !== media.mediaType) return
|
||||
complete(media: PhoneMedia): string | null {
|
||||
if (!this.request || this.request.mediaType !== media.mediaType) return null
|
||||
const returnPath = this.request.returnPath
|
||||
this.result = { ...this.request, media }
|
||||
this.request = null
|
||||
return returnPath
|
||||
},
|
||||
consume(phoneNumber: string): PhoneMedia | null {
|
||||
if (!this.result || this.result.phoneNumber !== phoneNumber) return null
|
||||
consume(target: string): PhoneMedia | null {
|
||||
if (!this.result || this.result.target !== target) return null
|
||||
const media = this.result.media
|
||||
this.result = null
|
||||
return media
|
||||
|
||||
@@ -29,6 +29,27 @@ const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
darkchat: {
|
||||
name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message',
|
||||
signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.',
|
||||
security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.',
|
||||
noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat',
|
||||
connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity',
|
||||
message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation',
|
||||
emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More',
|
||||
voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to',
|
||||
messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}',
|
||||
timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days',
|
||||
copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report',
|
||||
contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared',
|
||||
myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.',
|
||||
unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat',
|
||||
reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted',
|
||||
microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.',
|
||||
errors: {
|
||||
not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.',
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
name: 'Messages',
|
||||
newMessage: 'New message from {sender}',
|
||||
@@ -244,6 +265,58 @@ const defaultLocales: LocaleTree = {
|
||||
default: 'The radio request failed.',
|
||||
},
|
||||
},
|
||||
banking: {
|
||||
name: 'Banking',
|
||||
welcome: 'Welcome back',
|
||||
totalBalance: 'Total Balance',
|
||||
recentPeriod: 'in recent activity',
|
||||
actions: 'Banking actions',
|
||||
send: 'Send',
|
||||
accounts: 'Accounts',
|
||||
bankAccount: 'Bank account',
|
||||
cash: 'Cash',
|
||||
latestTransactions: 'Latest Transactions',
|
||||
allTransactions: 'All Transactions',
|
||||
viewAll: 'View all',
|
||||
noTransactions: 'Your banking activity will appear here.',
|
||||
home: 'Home',
|
||||
activity: 'Activity',
|
||||
chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
|
||||
navigation: 'Banking navigation',
|
||||
incoming: 'Incoming',
|
||||
outgoing: 'Outgoing',
|
||||
refresh: 'Refresh banking data',
|
||||
unavailable: 'Banking unavailable',
|
||||
tryAgain: 'Try Again',
|
||||
playerId: 'Player ID',
|
||||
playerIdPlaceholder: 'Enter the recipient ID',
|
||||
amount: 'Amount',
|
||||
amountPlaceholder: 'Enter an amount',
|
||||
transactions: {
|
||||
deposit: 'Cash deposit',
|
||||
withdrawal: 'Cash withdrawal',
|
||||
transfer_in: 'Incoming transfer',
|
||||
transfer_out: 'Outgoing transfer',
|
||||
},
|
||||
forms: {
|
||||
transfer: {
|
||||
title: 'Send money',
|
||||
body: 'Transfer money from your bank account to an online player.',
|
||||
submit: 'Send transfer',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
invalid_request: 'Enter a valid whole amount and player ID.',
|
||||
insufficient_funds: 'There is not enough money in this account.',
|
||||
target_not_found: 'The recipient is not online.',
|
||||
self_transfer: 'You cannot send money to yourself.',
|
||||
rate_limited: 'Please wait before making another transaction.',
|
||||
transfer_failed: 'The transfer could not be completed.',
|
||||
banking_unavailable: 'Banking is currently unavailable.',
|
||||
request_failed: 'The banking request failed.',
|
||||
default: 'The banking request failed.',
|
||||
},
|
||||
},
|
||||
calculator: { name: 'Calculator' },
|
||||
snake: {
|
||||
name: 'Snake',
|
||||
@@ -1048,6 +1121,7 @@ const defaultLocales: LocaleTree = {
|
||||
apps: 'Apps',
|
||||
dock: 'Dock',
|
||||
noApps: 'No apps found',
|
||||
removeApp: 'Remove {app} from Home Screen',
|
||||
page: 'Page',
|
||||
pages: 'Home screen pages',
|
||||
groups: {
|
||||
|
||||
Reference in New Issue
Block a user