diff --git a/frontend/src/stores/darkchat.test.ts b/frontend/src/stores/darkchat.test.ts index 2cedfd7..99f47bb 100644 --- a/frontend/src/stores/darkchat.test.ts +++ b/frontend/src/stores/darkchat.test.ts @@ -2,7 +2,11 @@ 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 type { + DarkChatMessage, + DarkChatProfile, + DarkChatThread, +} from '@/types/darkchat' import { nuiCall } from '@/utils/nui' vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) @@ -18,6 +22,17 @@ const conversation: DarkChatThread['conversation'] = { readReceipts: true, } +const profile: DarkChatProfile = { + activityVisible: false, + alias: 'Nightshade', + avatarSeed: 267, + createdAt: '2026-08-01 21:20:00', + darkId: 'dark:7X4K-P92D', + id: 1, + inviteCode: 'DC-7X4K-NOVA', + notificationMode: 'private', +} + function message(id: string): DarkChatMessage { return { body: 'Quiet channel', @@ -37,28 +52,53 @@ describe('darkchat store', () => { }) 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)) + 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 }) + .mockResolvedValueOnce({ + data: { conversation, messages: [] }, + success: true, + }) + .mockResolvedValueOnce({ + data: { contacts: [], conversations: [], profile: null }, + success: true, + }) .mockImplementationOnce(() => pending) - .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .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' }) + 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' }) + 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({ + data: { conversation, messages: [] }, + success: true, + }) + .mockResolvedValueOnce({ + data: { contacts: [], conversations: [], profile: null }, + success: true, + }) .mockResolvedValueOnce({ error: 'blocked', success: false }) const store = useDarkChatStore() @@ -69,10 +109,22 @@ describe('darkchat store', () => { 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 }) + .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) @@ -102,7 +154,41 @@ describe('darkchat store', () => { 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(store.mediaSources['voice-id']).toBe( + 'data:audio/webm;codecs=opus;base64,ZmFrZQ==', + ) expect(mockNuiCall).toHaveBeenCalledTimes(1) }) + + it('creates a private profile only through the explicit profile action', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { contacts: [], conversations: [], profile }, + success: true, + }) + + const store = useDarkChatStore() + expect(await store.createProfile()).toBe(true) + expect(store.profile).toEqual(profile) + expect(mockNuiCall).toHaveBeenCalledWith('darkchat:create-profile') + }) + + it('clears all private state after deleting the profile', async () => { + mockNuiCall + .mockResolvedValueOnce({ + data: { contacts: [], conversations: [], profile }, + success: true, + }) + .mockResolvedValueOnce({ success: true }) + + const store = useDarkChatStore() + await store.bootstrap() + store.activeConversation = conversation + store.messages = [message('message-id')] + + expect(await store.deleteProfile()).toBe(true) + expect(store.profile).toBeNull() + expect(store.activeConversation).toBeNull() + expect(store.messages).toEqual([]) + expect(mockNuiCall).toHaveBeenLastCalledWith('darkchat:delete-profile') + }) }) diff --git a/frontend/src/stores/darkchat.ts b/frontend/src/stores/darkchat.ts index 64c51ad..1b0b1b4 100644 --- a/frontend/src/stores/darkchat.ts +++ b/frontend/src/stores/darkchat.ts @@ -23,7 +23,10 @@ export const useDarkChatStore = defineStore('darkchat', () => { const loading = ref(false) const lastError = ref(null) const unreadCount = computed(() => - conversations.value.reduce((total, conversation) => total + conversation.unread, 0), + conversations.value.reduce( + (total, conversation) => total + conversation.unread, + 0, + ), ) async function bootstrap(): Promise { @@ -37,15 +40,37 @@ export const useDarkChatStore = defineStore('darkchat', () => { conversations.value = [] return false } - profile.value = response.data.profile + profile.value = response.data.profile ?? null contacts.value = response.data.contacts conversations.value = response.data.conversations return true } + async function createProfile(): Promise { + loading.value = true + const response = await nuiCall('darkchat:create-profile') + loading.value = false + lastError.value = response.error ?? null + if (!response.success || !response.data) return false + profile.value = response.data.profile ?? null + contacts.value = response.data.contacts + conversations.value = response.data.conversations + return true + } + + async function deleteProfile(): Promise { + const response = await nuiCall('darkchat:delete-profile') + lastError.value = response.error ?? null + if (!response.success) return false + reset() + return true + } + async function openThread(conversationId: string): Promise { loading.value = true - const response = await nuiCall('darkchat:thread', { conversationId }) + const response = await nuiCall('darkchat:thread', { + conversationId, + }) loading.value = false lastError.value = response.error ?? null if (!response.success || !response.data) return false @@ -61,20 +86,28 @@ export const useDarkChatStore = defineStore('darkchat', () => { async function refreshInbox(): Promise { const response = await nuiCall('darkchat:bootstrap') if (!response.success || !response.data) return false - profile.value = response.data.profile + profile.value = response.data.profile ?? null contacts.value = response.data.contacts conversations.value = response.data.conversations return true } - async function start(identifier: string): Promise> { - const response = await nuiCall<{ conversationId: string }>('darkchat:start', { identifier }) + async function start( + identifier: string, + ): Promise> { + const response = await nuiCall<{ conversationId: string }>( + 'darkchat:start', + { identifier }, + ) if (response.success) await refreshInbox() return response } - async function send(outgoing: DarkChatOutgoing): Promise> { - if (!activeConversation.value) return { success: false, error: 'invalid_conversation' } + async function send( + outgoing: DarkChatOutgoing, + ): Promise> { + 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 = { @@ -100,13 +133,16 @@ export const useDarkChatStore = defineStore('darkchat', () => { } messages.value.push(optimistic) if (outgoing.messageType === 'voice' && outgoing.mediaPayload) { - mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` + mediaSources.value[clientId] = + `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` } const response = await nuiCall('darkchat:send', { ...request, conversationId: activeConversation.value.id, }) - const index = messages.value.findIndex((message) => message.clientId === clientId) + const index = messages.value.findIndex( + (message) => message.clientId === clientId, + ) if (!response.success || !response.data) { if (index >= 0) messages.value[index].deliveryStatus = 'failed' return response @@ -115,7 +151,11 @@ export const useDarkChatStore = defineStore('darkchat', () => { delete mediaSources.value[clientId] if (source) mediaSources.value[response.data.id] = source if (index >= 0) { - messages.value[index] = { ...response.data, clientId, deliveryStatus: 'delivered' } + messages.value[index] = { + ...response.data, + clientId, + deliveryStatus: 'delivered', + } } await refreshInbox() return response @@ -123,13 +163,20 @@ export const useDarkChatStore = defineStore('darkchat', () => { async function loadMedia(messageId: string): Promise { if (mediaSources.value[messageId]) return true - const response = await nuiCall<{ mime: string; payload: string }>('darkchat:media', { messageId }) + 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}` + mediaSources.value[messageId] = + `data:${response.data.mime};base64,${response.data.payload}` return true } - async function mutate(endpoint: string, data: Record): Promise { + async function mutate( + endpoint: string, + data: Record, + ): Promise { const response = await nuiCall(`darkchat:${endpoint}`, data) return response.success } @@ -140,12 +187,25 @@ export const useDarkChatStore = defineStore('darkchat', () => { mediaSources.value = {} } + function reset(): void { + profile.value = null + contacts.value = [] + conversations.value = [] + activeConversation.value = null + messages.value = [] + mediaSources.value = {} + loading.value = false + lastError.value = null + } + return { activeConversation, bootstrap, closeThread, + createProfile, contacts, conversations, + deleteProfile, lastError, loadMedia, loading, @@ -155,6 +215,7 @@ export const useDarkChatStore = defineStore('darkchat', () => { openThread, profile, refreshInbox, + reset, send, start, unreadCount, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e71e097..0820f4f 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1483,6 +1483,9 @@ const defaultLocales: LocaleTree = { signInBody: 'DarkChat identities are linked to your private Sky Cloud account.', signInHint: 'Sign in through Settings to continue.', + createIdentity: 'Create Dark Identity', + createIdentityBody: + 'Create a private identity before starting a DarkChat conversation.', security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', @@ -1497,6 +1500,10 @@ const defaultLocales: LocaleTree = { darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', + contactsHint: + 'Saved private identities appear only on your DarkChat account.', + noContacts: 'No saved DarkChat contacts', + private: 'Private', shareIdentity: 'Tap to share your private identity', message: 'Dark message', activeNow: 'Activity shared', @@ -1535,7 +1542,10 @@ const defaultLocales: LocaleTree = { deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report', + messageActions: 'Message actions', contactSecurity: 'Contact & Security', + chatSettings: 'Chat Settings', + contactActions: 'Contact Actions', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', @@ -1552,6 +1562,7 @@ const defaultLocales: LocaleTree = { chatCleared: 'Chat cleared', myIdentity: 'My Dark Identity', alias: 'Alias', + privacySettings: 'Privacy Settings', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', @@ -1561,6 +1572,19 @@ const defaultLocales: LocaleTree = { copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.', + profileActions: 'Account Actions', + signOut: 'Sign Out', + signOutTitle: 'Sign out of Sky Cloud?', + signOutBody: + 'This signs the whole phone out of Sky Cloud. Your DarkChat profile and messages remain stored.', + signingOut: 'Signing Out...', + signOutHint: + 'Signing out affects every app that uses Sky Cloud on this phone.', + deleteProfile: 'Delete DarkChat Profile', + deleteProfileTitle: 'Delete DarkChat profile?', + deleteProfileBody: + 'Your identity, contacts and DarkChat conversations will be permanently deleted.', + deletingProfile: 'Deleting...', unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', @@ -1588,6 +1612,7 @@ const defaultLocales: LocaleTree = { invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', + sign_out_failed: 'Sky Cloud could not sign out.', 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.', diff --git a/frontend/src/types/darkchat.ts b/frontend/src/types/darkchat.ts index b79b812..bd8bff1 100644 --- a/frontend/src/types/darkchat.ts +++ b/frontend/src/types/darkchat.ts @@ -79,7 +79,7 @@ export type DarkChatMessage = { } export type DarkChatBootstrap = { - profile: DarkChatProfile + profile: DarkChatProfile | null contacts: DarkChatContact[] conversations: DarkChatConversationSummary[] } diff --git a/frontend/src/views/apps/DarkChatApp.contract.test.ts b/frontend/src/views/apps/DarkChatApp.contract.test.ts new file mode 100644 index 0000000..4de18fa --- /dev/null +++ b/frontend/src/views/apps/DarkChatApp.contract.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./DarkChatApp.vue', import.meta.url), + 'utf8', +) + +describe('DarkChatApp Sky UI contract', () => { + it('uses first-party Sky UI without direct Konsta markup', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toMatch(/<\/?k-[a-z]/) + expect(source).toContain(' { + const inboxStart = source.indexOf("screen === 'inbox'") + const newChatStart = source.indexOf("screen === 'new'") + const inbox = source.slice(inboxStart, newChatStart) + + expect(inbox.match(/@click="openProfile"/g)).toHaveLength(1) + }) + + it('bottom-aligns short threads and exposes profile lifecycle actions', () => { + expect(source).toContain('ref="messagesArea"') + expect(source).toMatch(/\.dc-day\s*\{[^}]*margin:\s*auto 0 8px/s) + expect(source).toContain('@click="signOut"') + expect(source).toContain('@click="deleteProfile"') + }) + + it('separates the round attachment action from the floating message pill', () => { + expect(source).toContain('class="dc-composer-row"') + expect(source).toContain('class="dc-composer-action"') + expect(source).toContain('class="dc-composer-pill"') + expect(source).toMatch( + /
[\s\S]*?[\s\S]*?') + expect(source).toMatch( + /\.dc-composer-pill\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill\)/s, + ) + expect(source).toMatch( + /\.dc-composer-action\s*\{[^}]*border-radius:\s*50%/s, + ) + }) + + it('raises only the DarkChat inbox title block', () => { + expect(source).toContain('class="dc-inbox-navbar"') + expect(source).toMatch( + /\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-6px\)/s, + ) + }) +}) diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue index 67666c8..5437d09 100644 --- a/frontend/src/views/apps/DarkChatApp.vue +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -1,37 +1,16 @@