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:
DerEchteAlec
2026-08-07 16:06:51 +02:00
50 changed files with 6108 additions and 164 deletions
+84
View File
@@ -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: {},
})
})
})
+86 -3
View File
@@ -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,
})
},
+63
View File
@@ -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')
})
})
+45
View File
@@ -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
},
},
})
+108
View File
@@ -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)
})
})
+160
View File
@@ -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,
}
})
+34
View File
@@ -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')
})
})
+17 -8
View File
@@ -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
+74
View File
@@ -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: {