ENH - refine DarkChat and Picstagram flows

This commit is contained in:
Leon.Schmidt
2026-08-16 20:49:40 +02:00
parent eb18190709
commit a97d09a6b2
10 changed files with 507 additions and 230 deletions
+2
View File
@@ -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',
+41 -23
View File
@@ -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([
+15 -3
View File
@@ -40,11 +40,12 @@ export function easyShareRoute(payload: EasySharePayload): {
path: string
query: Record<string, string>
} {
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<string, string> = {
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' }
@@ -16,7 +16,7 @@ describe('DarkChatApp Sky UI contract', () => {
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkyMessagebar')
expect(source).toContain('<SkyPillNavigation')
expect(source).not.toContain('<SkyPillNavigation')
})
it('keeps one identity action in the inbox header', () => {
@@ -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('<SquarePen :size="21" />')
})
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('<div v-else class="dc-composer-row">')
const composerEnd = source.indexOf('</section>', 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(
/<div v-else class="dc-composer-row">[\s\S]*?<SkyGlass[\s\S]*?class="dc-composer-action"[\s\S]*?<SkyGlass class="dc-composer-pill">[\s\S]*?<SkyMessagebar/,
/<div v-else class="dc-composer-row">[\s\S]*?class="dc-composer-pill"[\s\S]*?class="dc-composer-action"[\s\S]*?<SkyMessagebar/,
)
expect(source).not.toContain('<template #left>')
expect(composer).not.toContain('<template #left>')
expect(source).toMatch(
/\.dc-composer-pill\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill\)/s,
)
@@ -60,7 +94,7 @@ describe('DarkChatApp Sky UI contract', () => {
)
})
it('presents attachments as a compact five-action Sky glass rail', () => {
it('presents attachments as the vertical five-action SMS glass menu', () => {
const attachmentsStart = source.indexOf(
'v-if="attachmentOpen" class="dc-attachments"',
)
@@ -70,7 +104,16 @@ describe('DarkChatApp Sky UI contract', () => {
expect(attachments.match(/class="dc-attachment-action"/g)).toHaveLength(5)
expect(attachments.match(/<SkyGlass/g)).toHaveLength(5)
expect(source).toMatch(
/\.dc-attachments\s*\{[^}]*grid-template-columns:\s*repeat\(5,/s,
/\.dc-attachments\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;/s,
)
})
it('centers all composer controls on the same level', () => {
expect(source).toMatch(
/\.dc-composer-row \.dc-composer-pill\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;/s,
)
expect(source).toMatch(
/\.dc-composer-action\s*\{[^}]*width:\s*46px;[^}]*height:\s*46px;/s,
)
})
+235 -115
View File
@@ -20,6 +20,7 @@ import {
ShieldCheck,
ShieldOff,
Smile,
SquarePen,
Trash2,
UserRound,
UserMinus,
@@ -28,7 +29,7 @@ import {
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
@@ -49,6 +50,7 @@ import type { EasySharePayload } from '@/types/easyshare'
import type { MediaType, PhoneMedia } from '@/types/media'
import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import { easyShareDarkChatInviteCode } from '@/utils/easyshare'
import { handleEnterAction } from '@/utils/keyboard'
import {
SkyAppPage,
@@ -64,7 +66,6 @@ import {
SkyListItem,
SkyMessagebar,
SkyNavbar,
SkyPillNavigation,
SkyScrollArea,
SkySearchbar,
SkySettingsGroup,
@@ -88,6 +89,7 @@ const easyShare = useEasyShareStore()
const sms = useMessagesStore()
const messageMedia = useMessageMediaStore()
const phone = usePhoneStore()
const route = useRoute()
const router = useRouter()
const screen = ref<'inbox' | 'new' | 'thread' | 'contact' | 'profile'>('inbox')
const search = ref('')
@@ -147,6 +149,23 @@ const filteredConversations = computed(() => {
.includes(needle),
)
})
const contactSuggestions = computed(() => {
const needle = identifier.value.trim().toLocaleLowerCase(phone.lang)
if (!needle) return darkchat.contacts.slice(0, 8)
return darkchat.contacts
.filter((contact) =>
`${contact.alias} ${contact.darkId}`
.toLocaleLowerCase(phone.lang)
.includes(needle),
)
.slice(0, 8)
})
const sharedInviteCode = ref(
easyShareDarkChatInviteCode(
route.query.easyShareKind,
route.query.easyShareLink,
),
)
const active = computed(() => darkchat.activeConversation)
const attachmentPanelOpen = computed(() => emojiOpen.value || gifOpen.value)
const timerOptions = [0, -1, 60, 300, 3600, 86400, 604800]
@@ -342,6 +361,25 @@ function back(): void {
resetPanels()
}
function beginNewChat(): void {
identifier.value = ''
screen.value = 'new'
resetPanels()
}
function openSharedInvite(): void {
if (!sharedInviteCode.value || !darkchat.profile) return
identifier.value = sharedInviteCode.value
sharedInviteCode.value = null
screen.value = 'new'
resetPanels()
const query = { ...route.query }
delete query.easyShareId
delete query.easyShareKind
delete query.easyShareLink
void router.replace({ path: route.path, query })
}
function resetPanels(): void {
attachmentOpen.value = false
emojiOpen.value = false
@@ -704,6 +742,7 @@ async function createProfile(): Promise<void> {
const success = await darkchat.createProfile()
profileActionPending.value = false
if (!success) showToast(errorText(darkchat.lastError ?? undefined))
else openSharedInvite()
}
function selectDisappearing(value: number | string): void {
@@ -733,7 +772,7 @@ function chooseSelection(value: number | string): void {
selectionSheet.value = null
}
function shareIdentity(): void {
function sharePrivateInvite(): void {
const profile = darkchat.profile
if (!profile) return
easyShare.open({
@@ -918,6 +957,7 @@ function recordingTime(): string {
onMounted(async () => {
if (signedIn.value) await darkchat.bootstrap()
if (await openEasyShareDraft()) return
openSharedInvite()
if (!active.value) return
screen.value = 'thread'
const media = messageMedia.consume(`darkchat:${active.value.id}`)
@@ -1072,6 +1112,27 @@ onBeforeUnmount(() => {
margin: 0;
}
.dc-search :deep(.sky-searchbar__control),
.dc-search :deep(.sky-searchbar__input) {
height: 38px;
min-height: 38px;
}
.dc-search :deep(.sky-searchbar__input) {
font-size: 15px;
}
.dc-search :deep(.sky-searchbar__clear) {
width: 38px;
height: 38px;
}
.dc-search :deep(.sky-searchbar__clear::before) {
position: absolute;
inset: -3px;
content: '';
}
.dc-security {
width: 100%;
min-height: 58px;
@@ -1197,6 +1258,65 @@ onBeforeUnmount(() => {
margin: 0 2px 22px;
}
.dc-new-chat-scroll {
padding-top: 2px;
}
.dc-recipient-field,
.dc-new-chat-contacts {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
border-top: 1px solid var(--dc-border);
border-bottom: 1px solid var(--dc-border);
background: transparent;
}
.dc-recipient-field :deep(.sky-field) {
min-height: 52px;
padding: 0 14px;
display: flex;
align-items: center;
background: transparent;
}
.dc-recipient-field :deep(.sky-field__inner) {
min-width: 0;
padding: 0;
display: flex;
flex: 1;
align-items: center;
gap: 10px;
}
.dc-recipient-field :deep(.sky-field__label) {
margin: 0;
flex: none;
color: var(--dc-secondary);
font-size: 14px;
line-height: 20px;
}
.dc-recipient-field :deep(.sky-field__control),
.dc-recipient-field :deep(.sky-field__input) {
min-height: 52px;
}
.dc-recipient-field :deep(.sky-field__control) {
flex: 1;
margin: 0;
}
.dc-recipient-field :deep(.sky-field__input) {
font-size: 15px;
}
.dc-new-chat-contacts :deep(.sky-list-item__row) {
background: transparent;
}
.dc-identifier-action {
margin: 0;
}
.dc-contacts-group {
margin-bottom: 18px;
}
@@ -1266,6 +1386,19 @@ onBeforeUnmount(() => {
grid-column: 1 / -1;
}
.dc-invite-actions {
min-width: 0;
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.dc-invite-actions :deep(.sky-button) {
min-width: 0;
grid-column: auto;
}
.dc-thread-meta {
min-height: 28px;
display: flex;
@@ -1432,30 +1565,34 @@ onBeforeUnmount(() => {
}
.dc-attachments {
display: grid;
padding: 9px 10px 10px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
border-top: 1px solid var(--dc-border);
background: var(--dc-surface);
position: absolute;
z-index: 48;
bottom: calc(var(--sky-safe-area-bottom) + 78px);
left: calc(var(--sky-safe-area-left) + var(--sky-page-gutter));
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
.dc-attachment-action {
min-width: 0;
min-height: 72px;
width: auto;
min-height: 42px;
display: flex;
padding: 7px 2px 6px;
padding: 4px 14px 4px 5px;
align-items: center;
flex-direction: column;
gap: 5px;
border-radius: 18px;
gap: 9px;
border-radius: var(--sky-radius-pill);
color: var(--dc-label);
font-size: 12px;
font-weight: 650;
}
.dc-attachment-action > span {
width: 38px;
height: 38px;
width: 32px;
height: 32px;
display: grid;
flex: 0 0 32px;
border-radius: 50%;
color: var(--dc-accent);
background: var(--sky-app-accent-soft);
@@ -1463,13 +1600,11 @@ onBeforeUnmount(() => {
}
.dc-attachment-action > small {
width: 100%;
overflow: hidden;
font-size: 9px;
font-weight: 600;
line-height: 12px;
text-align: center;
text-overflow: ellipsis;
width: auto;
color: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
white-space: nowrap;
}
@@ -1569,30 +1704,43 @@ onBeforeUnmount(() => {
}
.dc-composer-row .dc-composer-pill {
width: 100%;
min-height: 56px;
display: flex;
margin: 0;
padding: 5px 7px;
align-items: center;
gap: 6px;
flex: 1 1 auto;
}
.dc-composer-action {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
box-sizing: border-box;
width: 46px;
height: 46px;
display: grid;
padding: 0;
overflow: hidden;
flex: 0 0 var(--sky-touch-target);
flex: 0 0 46px;
border-radius: 50%;
color: var(--dc-accent);
place-items: center;
transition:
color 180ms ease,
transform 180ms ease;
}
.dc-composer-action.active {
color: #fff;
background: var(--dc-accent);
transform: rotate(45deg);
}
.dc-composer {
min-width: 0;
padding: 6px;
padding: 0;
flex: 1;
gap: 4px;
background: transparent;
}
@@ -1826,15 +1974,19 @@ onBeforeUnmount(() => {
:subtitle="t('privateNetwork')"
variant="large"
>
<template #right>
<SkyGlass
component="button"
class="dc-navbar-action"
<template #left>
<SkyLink
icon-only
:aria-label="t('myIdentity')"
@click="openProfile"
>
<UserRound :size="20" />
</SkyGlass>
</SkyLink>
</template>
<template #right>
<SkyLink icon-only :aria-label="t('newChat')" @click="beginNewChat">
<SquarePen :size="21" />
</SkyLink>
</template>
<template #subnavbar>
<SkySearchbar
@@ -1847,7 +1999,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded with-tabbar class="dc-inbox-content">
<SkyScrollArea padded class="dc-inbox-content">
<SkyGlass
component="button"
class="dc-security"
@@ -1910,68 +2062,36 @@ onBeforeUnmount(() => {
<template #icon><ShieldCheck :size="27" /></template>
</SkyEmptyState>
</SkyScrollArea>
<SkyPillNavigation
layout="compact"
align="end"
:label="t('newChat')"
class="dc-inbox-navigation"
>
<SkyButton raised rounded @click="screen = 'new'">
<MessageCirclePlus :size="19" />{{ t('newChat') }}
</SkyButton>
</SkyPillNavigation>
</section>
<section v-else-if="screen === 'new'" class="dc-screen">
<SkyNavbar
:title="t('newChat')"
show-back
back-appearance="surface"
:back-label="phone.t('Common.back')"
@back="back"
/>
<SkyScrollArea padded class="dc-scroll-content">
<SkyCard class="dc-hero" outline>
<span class="dc-icon-tile"><QrCode :size="27" /></span>
<h2>{{ t('connectPrivately') }}</h2>
<p>{{ t('newChatBody') }}</p>
</SkyCard>
<SkyList
inset
strong
class="dc-form-list"
@keydown.enter="handleEnterAction($event, requestStart)"
>
<SkyNavbar :title="t('newChat')">
<template #right>
<SkyLink @click="back">{{ phone.t('Common.cancel') }}</SkyLink>
</template>
</SkyNavbar>
<SkyScrollArea padded class="dc-new-chat-scroll">
<SkyList class="dc-recipient-field" density="compact" flush>
<SkyField
:label="t('darkIdOrInvite')"
:label="t('to')"
layout="inline"
:value="identifier"
autocomplete="off"
placeholder="dark:7X4K-P92D"
:placeholder="t('darkIdOrInvite')"
clear-button
:clear-label="phone.t('Common.clear')"
@input="setIdentifier"
@clear="identifier = ''"
@keyup.enter="requestStart()"
/>
</SkyList>
<SkyButton
large
rounded
block
class="dc-primary"
:disabled="!identifier.trim()"
@click="requestStart()"
>
{{ t('continue') }}
</SkyButton>
<SkySettingsGroup
:title="t('contacts')"
:footer="t('contactsHint')"
class="dc-contacts-group"
<SkyList
v-if="contactSuggestions.length"
class="dc-new-chat-contacts"
flush
>
<SkyListItem
v-for="contact in darkchat.contacts"
v-for="contact in contactSuggestions"
:key="contact.id"
link
link-component="button"
@@ -1995,22 +2115,17 @@ onBeforeUnmount(() => {
</span>
</template>
</SkyListItem>
<li v-if="!darkchat.contacts.length" class="dc-contacts-empty">
<UserRound :size="22" />{{ t('noContacts') }}
</li>
</SkySettingsGroup>
<SkyCard class="dc-qr-card" outline :content-wrap="false">
<div class="dc-faux-qr"><QrCode :size="58" /></div>
<div>
<strong>{{ darkchat.profile.darkId }}</strong>
<small>{{ t('shareIdentity') }}</small>
</div>
<SkyButton tonal rounded @click="shareIdentity">
<Share2 :size="16" />
{{ phone.t('Apps.easyShare.shareProfile') }}
</SkyButton>
</SkyCard>
</SkyList>
<SkyButton
v-else-if="identifier.trim()"
rounded
tonal
class="dc-identifier-action"
@click="requestStart()"
>
<MessageCirclePlus :size="17" />
{{ identifier }}
</SkyButton>
</SkyScrollArea>
</section>
@@ -2234,16 +2349,16 @@ onBeforeUnmount(() => {
</SkyLink>
</SkyGlass>
<div v-else class="dc-composer-row">
<SkyGlass
component="button"
class="dc-composer-action"
:class="{ active: attachmentOpen || attachmentPanelOpen }"
:aria-label="phone.t('Common.add')"
@click="toggleAttachmentPanel"
>
<Plus :size="23" />
</SkyGlass>
<SkyGlass class="dc-composer-pill">
<SkyGlass component="div" :highlight="false" class="dc-composer-pill">
<SkyGlass
component="button"
class="dc-composer-action"
:class="{ active: attachmentOpen || attachmentPanelOpen }"
:aria-label="phone.t('Common.add')"
@click="toggleAttachmentPanel"
>
<Plus :size="23" />
</SkyGlass>
<SkyMessagebar
class="dc-composer"
embedded
@@ -2448,13 +2563,18 @@ onBeforeUnmount(() => {
<strong>{{ darkchat.profile.inviteCode }}</strong>
<small>{{ t('inviteCode') }}</small>
</div>
<SkyButton
tonal
rounded
@click="copyIdentity(darkchat.profile.inviteCode)"
>
<Copy :size="16" />{{ t('copyInvite') }}
</SkyButton>
<div class="dc-invite-actions">
<SkyButton
clear
rounded
@click="copyIdentity(darkchat.profile.inviteCode)"
>
<Copy :size="16" />{{ t('copyInvite') }}
</SkyButton>
<SkyButton tonal rounded @click="sharePrivateInvite">
<Share2 :size="16" />{{ t('shareInvite') }}
</SkyButton>
</div>
</SkyCard>
<p class="dc-privacy-note">
<LockKeyhole :size="17" />{{ t('privacyDisclaimer') }}
@@ -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,
)
})
})
+2 -1
View File
@@ -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 {
@@ -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('<SkyDropdown')
expect(source).toContain(':target="postMenuTarget"')
expect(source).toContain('@select="selectPostMenuItem"')
expect(source).toContain('showPostActions($event, post)')
expect(source).toContain("case 'share':")
})
it('opens comments independently from post detail and collapses replies', () => {
@@ -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)',
)
+138 -78
View File
@@ -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<PicstagramComment | null>(null)
const actionsOpen = ref(false)
const postMenuOpened = ref(false)
const postMenuTarget = ref<HTMLElement | null>(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<void> {
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)"
>
<MoreHorizontal />
</SkyLink>
@@ -1099,7 +1153,7 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="tab === 'home'">
<SkyNavbar :title="t('name')" variant="compact">
<SkyNavbar class="ps-home-navbar" :title="t('name')" variant="compact">
<template #left>
<SkyLink
component="button"
@@ -1190,7 +1244,7 @@ onBeforeUnmount(() => {
icon-only
class="ps-post-more"
:aria-label="t('more')"
@click.stop="showPostActions(post)"
@click="showPostActions($event, post)"
>
<MoreHorizontal />
</SkyLink>
@@ -2054,40 +2108,44 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="ps-edit-fields">
<SkyField
v-model="profileDraft.displayName"
:label="t('displayName')"
outline
/><SkyField
v-model="profileDraft.handle"
:label="t('username')"
outline
/><SkyField
v-model="profileDraft.bio"
:label="t('bio')"
type="textarea"
:rows="6"
outline
/>
<SkyList inset strong class="ps-edit-field-list">
<SkyField
v-model="profileDraft.displayName"
:label="t('displayName')"
maxlength="40"
/>
<SkyField
v-model="profileDraft.handle"
:label="t('username')"
maxlength="24"
/>
<SkyField
v-model="profileDraft.bio"
:label="t('bio')"
type="textarea"
:rows="4"
maxlength="160"
/>
</SkyList>
<section class="ps-privacy-control">
<span class="ps-privacy-control__icon">
<LockKeyhole v-if="profileDraft.private" />
<UserRound v-else />
</span>
<div>
<strong>{{ t('profileVisibility') }}</strong>
<strong>{{
t(
profileDraft.private
? 'privateProfileSetting'
: 'publicProfile',
)
}}</strong>
<small>{{ t('privacyHint') }}</small>
</div>
<SkySegmented strong>
<SkySegmentedButton
:active="!profileDraft.private"
@click="profileDraft.private = false"
>
<UserRound />{{ t('publicProfile') }}
</SkySegmentedButton>
<SkySegmentedButton
:active="profileDraft.private"
@click="profileDraft.private = true"
>
<LockKeyhole />{{ t('privateProfileSetting') }}
</SkySegmentedButton>
</SkySegmented>
<SkyToggle
v-model="profileDraft.private"
:aria-label="t('privateProfileSetting')"
/>
</section>
</div>
</section>
@@ -2340,32 +2398,25 @@ onBeforeUnmount(() => {
</section></SkySheet
>
<SkyDropdown
:items="postMenuItems"
:label="t('more')"
:opened="postMenuOpened"
placement="auto"
:target="postMenuTarget"
@backdropclick="dismissPostMenu"
@escape="dismissPostMenu"
@positionerror="dismissPostMenu"
@select="selectPostMenuItem"
/>
<SkyActionSheet
:opened="actionsOpen"
:label="t('more')"
@backdropclick="actionsOpen = false"
@escape="actionsOpen = false"
>
<SkyActionGroup v-if="actionPost"
><SkyActionButton
v-if="actionPost.is_owner"
@click="archiveSelectedPost"
>{{ t('archive') }}</SkyActionButton
><SkyActionButton
v-if="actionPost.is_owner"
@click="((deleteDialogOpen = true), (actionsOpen = false))"
>{{ t('deletePost') }}</SkyActionButton
><SkyActionButton
v-else
@click="startReport('post', actionPost.id, t('post'))"
>{{ t('report') }}</SkyActionButton
><SkyActionButton
v-if="!actionPost.is_owner"
@click="((blockDialogOpen = true), (actionsOpen = false))"
>{{ t('block') }}</SkyActionButton
></SkyActionGroup
>
<SkyActionGroup v-else-if="currentProfile"
<SkyActionGroup v-if="currentProfile"
><SkyActionButton @click="shareCurrentProfile">{{
t('shareProfile')
}}</SkyActionButton
@@ -2583,6 +2634,11 @@ button {
line-height: 16px;
}
.ps-home-navbar :deep(.sky-navbar__inner) {
margin-bottom: 2px;
transform: translateY(-3px);
}
.ps-stories {
display: flex;
gap: 14px;
@@ -3672,11 +3728,16 @@ button {
font-size: 12px;
}
.ps-edit-fields {
padding: 0 var(--sky-page-gutter);
padding: 0;
}
.ps-edit-field-list {
margin-bottom: 0;
}
.ps-privacy-control {
display: grid;
gap: 10px;
display: flex;
align-items: center;
gap: 11px;
margin: 0 var(--sky-page-gutter);
padding: 13px 14px;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
@@ -3684,23 +3745,30 @@ button {
}
.ps-privacy-control > div {
display: grid;
min-width: 0;
flex: 1;
gap: 2px;
}
.ps-privacy-control__icon {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 34px;
place-items: center;
border-radius: 50%;
background: var(--ps-accent-soft);
color: var(--ps-accent);
}
.ps-privacy-control small {
color: var(--sky-muted);
font-size: 11px;
line-height: 1.35;
}
.ps-privacy-control :deep(.sky-segmented-button) {
min-width: 0;
gap: 5px;
}
.ps-privacy-control :deep(svg) {
.ps-privacy-control__icon svg {
width: 15px;
height: 15px;
}
.ps-fields :deep(.sky-field),
.ps-edit-fields :deep(.sky-field) {
.ps-fields :deep(.sky-field) {
overflow: hidden;
margin: 0;
padding: 12px 14px;
@@ -3708,34 +3776,26 @@ button {
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.ps-fields :deep(.sky-field__inner),
.ps-edit-fields :deep(.sky-field__inner) {
.ps-fields :deep(.sky-field__inner) {
padding: 0;
}
.ps-fields :deep(.sky-field__label),
.ps-edit-fields :deep(.sky-field__label) {
.ps-fields :deep(.sky-field__label) {
margin-top: 0;
}
.ps-fields :deep(.sky-field__label-text),
.ps-edit-fields :deep(.sky-field__label-text) {
.ps-fields :deep(.sky-field__label-text) {
top: auto;
margin: 0;
padding: 0;
background: transparent;
}
.ps-fields :deep(.sky-field__control),
.ps-edit-fields :deep(.sky-field__control) {
.ps-fields :deep(.sky-field__control) {
margin: 0;
border-radius: calc(var(--sky-radius-control) + 10px);
background: var(--sky-surface-muted);
}
.ps-fields :deep(.sky-field__border),
.ps-edit-fields :deep(.sky-field__border) {
.ps-fields :deep(.sky-field__border) {
display: none;
}
.ps-edit-fields :deep(.sky-field__textarea) {
padding-inline: 4px;
}
.ps-compose-fields :deep(.sky-field) {
overflow: hidden;
margin: 0;