diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index fb991b8..3324b14 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1644,6 +1644,7 @@ const defaultLocales: LocaleTree = { noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat', + to: 'To:', connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', @@ -1720,6 +1721,7 @@ const defaultLocales: LocaleTree = { shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', + shareInvite: 'Share Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.', profileActions: 'Account Actions', diff --git a/frontend/src/utils/easyshare.test.ts b/frontend/src/utils/easyshare.test.ts index 5a49f67..1ff3253 100644 --- a/frontend/src/utils/easyshare.test.ts +++ b/frontend/src/utils/easyshare.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import type { EasySharePayload } from '@/types/easyshare' import { easyShareCrewLinkInviteCode, + easyShareDarkChatInviteCode, easyShareDestinationAppIds, easyShareMusicTarget, easyShareRoute, @@ -89,11 +90,25 @@ describe('EasyShare deep links', () => { it('accepts the canonical payload id as a CrewLink invite fallback', () => { expect(easyShareCrewLinkInviteCode('link', '', 'n1ght247')).toBe('N1GHT247') + expect(easyShareCrewLinkInviteCode('profile', '', 'n1ght247')).toBeNull() + expect(easyShareCrewLinkInviteCode('link', '', 'invalid-code')).toBeNull() + }) + + it('extracts a private DarkChat invitation from its profile share', () => { expect( - easyShareCrewLinkInviteCode('profile', '', 'n1ght247'), + easyShareDarkChatInviteCode( + 'profile', + 'skyphone://darkchat/invite/dc-7x4k-nova', + ), + ).toBe('DC-7X4K-NOVA') + expect( + easyShareDarkChatInviteCode( + 'link', + 'skyphone://darkchat/invite/DC-7X4K-NOVA', + ), ).toBeNull() expect( - easyShareCrewLinkInviteCode('link', '', 'invalid-code'), + easyShareDarkChatInviteCode('profile', 'skyphone://darkchat/invite/bad'), ).toBeNull() }) @@ -113,9 +128,12 @@ describe('EasyShare deep links', () => { 'skyphone://music/playlist/music-playlist-1', { id: 'music-playlist-1', kind: 'playlist' }, ], - ] as const)('extracts the exact shared music target', (kind, link, target) => { - expect(easyShareMusicTarget(kind, link)).toEqual(target) - }) + ] as const)( + 'extracts the exact shared music target', + (kind, link, target) => { + expect(easyShareMusicTarget(kind, link)).toEqual(target) + }, + ) it('rejects mismatched music share kinds', () => { expect( @@ -128,17 +146,14 @@ describe('EasyShare deep links', () => { }) describe('EasyShare destination suggestions', () => { - it.each([ - 'document', - 'link', - 'location', - 'note', - 'text', - ] as const)('offers Notes for %s content from another app', (kind) => { - expect( - easyShareDestinationAppIds(payload({ appId: 'calendar', kind })), - ).toEqual(['messages', 'darkchat', 'flare', 'notes']) - }) + it.each(['document', 'link', 'location', 'note', 'text'] as const)( + 'offers Notes for %s content from another app', + (kind) => { + expect( + easyShareDestinationAppIds(payload({ appId: 'calendar', kind })), + ).toEqual(['messages', 'darkchat', 'flare', 'notes']) + }, + ) it.each([ 'contact', @@ -148,13 +163,16 @@ describe('EasyShare destination suggestions', () => { 'profile', 'track', 'video', - ] as const)('does not suggest lossy Notes conversion for %s content', (kind) => { - expect(easyShareDestinationAppIds(payload({ kind }))).toEqual([ - 'messages', - 'darkchat', - 'flare', - ]) - }) + ] as const)( + 'does not suggest lossy Notes conversion for %s content', + (kind) => { + expect(easyShareDestinationAppIds(payload({ kind }))).toEqual([ + 'messages', + 'darkchat', + 'flare', + ]) + }, + ) it('does not suggest saving a Notes item back into Notes', () => { expect(easyShareDestinationAppIds(payload({}))).toEqual([ diff --git a/frontend/src/utils/easyshare.ts b/frontend/src/utils/easyshare.ts index 05521ca..06c80d1 100644 --- a/frontend/src/utils/easyshare.ts +++ b/frontend/src/utils/easyshare.ts @@ -40,11 +40,12 @@ export function easyShareRoute(payload: EasySharePayload): { path: string query: Record } { - const match = payload.link?.match(/^skyphone:\/\/([^/]+)(?:\/([^/]+))?(?:\/(.+))?$/) + const match = payload.link?.match( + /^skyphone:\/\/([^/]+)(?:\/([^/]+))?(?:\/(.+))?$/, + ) const schemeApp = match?.[1] const app = schemeApp ? getPhoneApp(schemeApp) : getPhoneApp(payload.appId) - const path = - (schemeApp && schemeRoutes[schemeApp]) || app?.route || '/' + const path = (schemeApp && schemeRoutes[schemeApp]) || app?.route || '/' const query: Record = { easyShareId: String(payload.id ?? match?.[3] ?? match?.[2] ?? ''), easyShareKind: payload.kind, @@ -75,6 +76,17 @@ export function easyShareCrewLinkInviteCode( return null } +export function easyShareDarkChatInviteCode( + kind: unknown, + link: unknown, +): string | null { + if (kind !== 'profile' || typeof link !== 'string') return null + const match = link.match( + /^skyphone:\/\/darkchat\/invite\/(DC-[A-Z0-9]{4}-[A-Z0-9]{4})$/i, + ) + return match ? match[1].toUpperCase() : null +} + export type EasyShareMusicTarget = | { id: string; kind: 'playlist' } | { id: string; kind: 'track'; source: 'server' | 'youtube' } diff --git a/frontend/src/views/apps/DarkChatApp.contract.test.ts b/frontend/src/views/apps/DarkChatApp.contract.test.ts index 2b1daf0..7a59114 100644 --- a/frontend/src/views/apps/DarkChatApp.contract.test.ts +++ b/frontend/src/views/apps/DarkChatApp.contract.test.ts @@ -16,7 +16,7 @@ describe('DarkChatApp Sky UI contract', () => { expect(source).toContain(' { @@ -25,6 +25,36 @@ describe('DarkChatApp Sky UI contract', () => { const inbox = source.slice(inboxStart, newChatStart) expect(inbox.match(/@click="openProfile"/g)).toHaveLength(1) + expect(inbox).toContain('@click="beginNewChat"') + expect(inbox).toContain('') + }) + + it('matches the SMS recipient composer for new private chats', () => { + const newChatStart = source.indexOf("screen === 'new'") + const threadStart = source.indexOf("screen === 'thread'", newChatStart) + const newChat = source.slice(newChatStart, threadStart) + + expect(newChat).toContain('class="dc-recipient-field"') + expect(newChat).toContain('layout="inline"') + expect(newChat).toContain('class="dc-new-chat-contacts"') + expect(newChat).toContain("{{ phone.t('Common.cancel') }}") + expect(newChat).not.toContain('class="dc-hero"') + }) + + it('keeps the search visually compact without shrinking its wrapper', () => { + expect(source).toMatch( + /\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/, + ) + }) + + it('shares and consumes private profile invitations', () => { + expect(source).toContain('function sharePrivateInvite(): void') + expect(source).toContain('@click="sharePrivateInvite"') + expect(source).toContain('easyShareDarkChatInviteCode(') + expect(source).toContain('function openSharedInvite(): void') + expect(source).toMatch( + /openSharedInvite\(\)[\s\S]*?identifier\.value = sharedInviteCode\.value[\s\S]*?screen\.value = 'new'/, + ) }) it('uses the full conversation card width', () => { @@ -44,14 +74,18 @@ describe('DarkChatApp Sky UI contract', () => { expect(source).toContain('@click="deleteProfile"') }) - it('separates the round attachment action from the floating message pill', () => { + it('aligns the attachment action, input, and send icon in one composer pill', () => { + const composerStart = source.indexOf('
') + const composerEnd = source.indexOf('', composerStart) + const composer = source.slice(composerStart, composerEnd) + 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]*?[\s\S]*?class="dc-composer-pill"[\s\S]*?class="dc-composer-action"[\s\S]*?') + expect(composer).not.toContain(' -
  • - {{ t('noContacts') }} -
  • - - - -
    -
    - {{ darkchat.profile.darkId }} - {{ t('shareIdentity') }} -
    - - - {{ phone.t('Apps.easyShare.shareProfile') }} - -
    + + + + {{ identifier }} + @@ -2234,16 +2349,16 @@ onBeforeUnmount(() => {
    - - - - + + + + { {{ darkchat.profile.inviteCode }} {{ t('inviteCode') }}
    - - {{ t('copyInvite') }} - +
    + + {{ t('copyInvite') }} + + + {{ t('shareInvite') }} + +

    {{ t('privacyDisclaimer') }} diff --git a/frontend/src/views/apps/HouseApp.contract.test.ts b/frontend/src/views/apps/HouseApp.contract.test.ts index 14c48c6..eb3b873 100644 --- a/frontend/src/views/apps/HouseApp.contract.test.ts +++ b/frontend/src/views/apps/HouseApp.contract.test.ts @@ -43,4 +43,10 @@ describe('House app sheets', () => { /\.house-key-list\s*\{[^}]*--sky-list-outer-left:\s*0px;[^}]*--sky-list-outer-right:\s*0px;/s, ) }) + + it('centers the resident icon above the Give a Key title', () => { + expect(source).toMatch( + /\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s, + ) + }) }) diff --git a/frontend/src/views/apps/HouseApp.vue b/frontend/src/views/apps/HouseApp.vue index e0ee700..1f015c1 100644 --- a/frontend/src/views/apps/HouseApp.vue +++ b/frontend/src/views/apps/HouseApp.vue @@ -1046,7 +1046,8 @@ onBeforeUnmount(() => { text-align: center; } .house-candidates > svg { - margin-top: 0; + display: block; + margin: 0 auto; color: var(--house-accent); } .house-candidates h2 { diff --git a/frontend/src/views/apps/PicstagramApp.contract.test.ts b/frontend/src/views/apps/PicstagramApp.contract.test.ts index 0cdc0c7..99bf7f7 100644 --- a/frontend/src/views/apps/PicstagramApp.contract.test.ts +++ b/frontend/src/views/apps/PicstagramApp.contract.test.ts @@ -15,6 +15,18 @@ describe('Picstagram app layout', () => { expect(source).toContain('ps-post-more') expect(source).toContain('count(post.like_count)') expect(source).toContain('count(post.comment_count)') + expect(source).toContain('class="ps-home-navbar"') + expect(source).toMatch( + /\.ps-home-navbar :deep\(\.sky-navbar__inner\)\s*\{[^}]*margin-bottom:\s*2px;[^}]*translateY\(-3px\)/s, + ) + }) + + it('uses the central anchored dropdown for image actions', () => { + expect(source).toContain(' { @@ -51,8 +63,11 @@ describe('Picstagram app layout', () => { it('keeps create out of navigation and exposes privacy requests', () => { expect(source).toContain(':item-count="4"') expect(source).not.toContain('ps-create-tab') - expect(source).toContain('profileDraft.private = false') - expect(source).toContain('profileDraft.private = true') + expect(source).toContain('v-model="profileDraft.private"') + expect(source).toContain('class="ps-edit-field-list"') + expect(source).toContain('class="ps-privacy-control__icon"') + expect(source).not.toContain('profileDraft.private = false') + expect(source).not.toContain('profileDraft.private = true') expect(source).toContain( 'respondToFollowRequest(activity.profile_id, true)', ) diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue index 8f253b8..6666c77 100644 --- a/frontend/src/views/apps/PicstagramApp.vue +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -57,6 +57,7 @@ import { SkyChip, SkyDialog, SkyDialogButton, + SkyDropdown, SkyField, SkyGlass, SkyLink, @@ -113,6 +114,8 @@ const commentsOpen = ref(false) const commentBody = ref('') const replyingTo = ref(null) const actionsOpen = ref(false) +const postMenuOpened = ref(false) +const postMenuTarget = ref(null) const reportOpen = ref(false) const reportTarget = ref<{ id: string @@ -158,6 +161,27 @@ const tabIndex = computed(() => const currentComposeMedia = computed( () => selectedMedia.value[composePreviewIndex.value] ?? null, ) +const postMenuItems = computed(() => { + const post = actionPost.value + if (!post) return [] + if (post.is_owner) { + return [ + { id: 'share', label: t('share') }, + { id: 'archive', label: t('archive') }, + { + destructive: true, + id: 'delete', + label: t('deletePost'), + separatorBefore: true, + }, + ] + } + return [ + { id: 'share', label: t('share') }, + { id: 'report', label: t('report'), separatorBefore: true }, + { destructive: true, id: 'block', label: t('block') }, + ] +}) const taggedProfilePosts = computed(() => { const handle = currentProfile.value?.handle.trim().toLowerCase() if (!handle) return [] @@ -612,9 +636,39 @@ async function saveProfile(): Promise { notify(t('profileSaved')) } -function showPostActions(post: PicstagramPost): void { +function showPostActions(event: MouseEvent, post: PicstagramPost): void { + event.stopPropagation() actionPost.value = post - actionsOpen.value = true + postMenuTarget.value = event.currentTarget as HTMLElement + postMenuOpened.value = true +} + +function dismissPostMenu(): void { + postMenuOpened.value = false + postMenuTarget.value = null +} + +function selectPostMenuItem(id: string): void { + const post = actionPost.value + if (!post) return + dismissPostMenu() + switch (id) { + case 'share': + sharePost(post) + break + case 'archive': + archiveSelectedPost() + break + case 'delete': + deleteDialogOpen.value = true + break + case 'report': + startReport('post', post.id, t('post')) + break + case 'block': + blockDialogOpen.value = true + break + } } function showProfileActions(): void { @@ -979,7 +1033,7 @@ onBeforeUnmount(() => { icon-only class="ps-post-more" :aria-label="t('more')" - @click="showPostActions(selectedPost)" + @click="showPostActions($event, selectedPost)" > @@ -1099,7 +1153,7 @@ onBeforeUnmount(() => {