feat(messages): add draggable contact share sheet

This commit is contained in:
Leon.Schmidt
2026-08-17 01:34:43 +02:00
parent ecfe089051
commit d9ada55ebc
3 changed files with 209 additions and 96 deletions
@@ -6,6 +6,10 @@ const source = readFileSync(
new URL('./MessagesApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('MessagesApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
@@ -22,6 +26,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkySettingsRow')
expect(source).toContain('<SkySheet')
expect(source).toContain('<SkyToolbar')
expect(source).not.toContain('<SkySegmented')
})
@@ -183,6 +188,30 @@ describe('MessagesApp Sky UI contract', () => {
)
})
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
const sheetStart = source.indexOf('class="messages-media-picker-sheet"')
const sheetEnd = source.indexOf('</SkySheet>', sheetStart)
const sheet = source.slice(sheetStart, sheetEnd)
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
':opened="activeCanMessage && attachmentPicker !== null"',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
expect(sheet).toContain('@backdropclick="closeAttachmentPicker"')
expect(sheet).toContain('@escape="closeAttachmentPicker"')
expect(sheet).toContain('@grabberclick="closeAttachmentPicker"')
expect(sheet).toContain('@swipeclose="closeAttachmentPicker"')
expect(sheet).toContain('v-if="attachmentPicker === \'contacts\'"')
expect(sheet).toContain('@click="sendContact(contact)"')
expect(source).toContain('function closeAttachmentPicker(): void')
expect(styles).toMatch(
/\.messages-media-picker-sheet \.sky-sheet__panel\s*\{[^}]*border-radius:\s*30px 30px 0 0/s,
)
})
it('uses a surface back target in the thread and a compact recipient row', () => {
const threadStart = source.indexOf(
'class="messages-sky-page messages-sky-thread"',
@@ -278,10 +307,7 @@ describe('MessagesApp Sky UI contract', () => {
)
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
const blockDialogOpened = source.indexOf(':opened="blockDialogOpened"')
const blockDialogStart = source.lastIndexOf(
'<SkyDialog',
blockDialogOpened,
)
const blockDialogStart = source.lastIndexOf('<SkyDialog', blockDialogOpened)
const toastStart = source.indexOf('<SkyToast', blockDialogStart)
const blockDialog = source.slice(blockDialogStart, toastStart)
+115 -92
View File
@@ -73,6 +73,7 @@ import {
SkySearchbar,
SkySettingsGroup,
SkySettingsRow,
SkySheet,
SkySpinner,
SkyToast,
SkyToolbar,
@@ -618,6 +619,10 @@ function openContactPicker(): void {
emojiOpen.value = false
}
function closeAttachmentPicker(): void {
attachmentPicker.value = null
}
function openEmojiPicker(): void {
attachmentMenuOpen.value = false
attachmentPicker.value = null
@@ -1639,103 +1644,121 @@ onBeforeUnmount(() => {
</SkyGlass>
</section>
<section
v-if="activeCanMessage && attachmentPicker"
class="messages-media-picker"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
<SkySheet
class="messages-media-picker-sheet"
:opened="activeCanMessage && attachmentPicker !== null"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
"
swipe-to-close
grabber-clickable
:grabber-label="phone.t('Common.close')"
@backdropclick="closeAttachmentPicker"
@escape="closeAttachmentPicker"
@grabberclick="closeAttachmentPicker"
@swipeclose="closeAttachmentPicker"
>
<header>
<strong>
{{
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
}}
</strong>
<SkyLink @click="attachmentPicker = null">
{{ phone.t('Common.done') }}
</SkyLink>
</header>
<SkyList
v-if="attachmentPicker === 'contacts'"
inset
strong
class="messages-media-picker__contacts"
<section
class="messages-media-picker"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<SkyListItem
v-for="contact in calls.contacts"
:key="contact.id"
link
link-component="button"
:title="contact.name"
:subtitle="contact.organization || contact.phone_number"
@click="sendContact(contact)"
<header>
<strong>
{{
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
}}
</strong>
<SkyLink @click="closeAttachmentPicker">
{{ phone.t('Common.done') }}
</SkyLink>
</header>
<SkyList
v-if="attachmentPicker === 'contacts'"
inset
strong
class="messages-media-picker__contacts"
>
<template #media>
<span class="messages-avatar messages-avatar--small">
<img
v-if="contact.avatar_url"
class="messages-avatar__image"
:src="contact.avatar_url"
alt=""
/>
<span v-else class="messages-avatar__initials">{{
contactInitials(contact.phone_number)
}}</span>
</span>
</template>
</SkyListItem>
<p v-if="!calls.contacts.length" class="messages-media-picker__empty">
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
:label="phone.t('Apps.messages.searchGifs')"
:placeholder="phone.t('Apps.messages.searchGifs')"
:clear-label="phone.t('Common.clear')"
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
class="messages-gif-more"
@click="loadGifs()"
>
{{ phone.t('Apps.messages.loadMore') }}
</button>
<div v-if="gifError && !gifLoading" class="messages-gif-error">
<ImagePlay :size="24" />
<strong>{{ errorText(gifError) }}</strong>
<button type="button" @click="loadGifs(true)">
{{ phone.t('Apps.messages.retryGifs') }}
<SkyListItem
v-for="contact in calls.contacts"
:key="contact.id"
link
link-component="button"
:title="contact.name"
:subtitle="contact.organization || contact.phone_number"
@click="sendContact(contact)"
>
<template #media>
<span class="messages-avatar messages-avatar--small">
<img
v-if="contact.avatar_url"
class="messages-avatar__image"
:src="contact.avatar_url"
alt=""
/>
<span v-else class="messages-avatar__initials">{{
contactInitials(contact.phone_number)
}}</span>
</span>
</template>
</SkyListItem>
<p v-if="!calls.contacts.length" class="messages-media-picker__empty">
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
:label="phone.t('Apps.messages.searchGifs')"
:placeholder="phone.t('Apps.messages.searchGifs')"
:clear-label="phone.t('Common.clear')"
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
class="messages-gif-more"
@click="loadGifs()"
>
{{ phone.t('Apps.messages.loadMore') }}
</button>
<div v-if="gifError && !gifLoading" class="messages-gif-error">
<ImagePlay :size="24" />
<strong>{{ errorText(gifError) }}</strong>
<button type="button" @click="loadGifs(true)">
{{ phone.t('Apps.messages.retryGifs') }}
</button>
</div>
<SkySpinner
v-if="gifLoading"
class="messages-gif-loading"
:label="phone.t('Common.loading')"
/>
</div>
<SkySpinner
v-if="gifLoading"
class="messages-gif-loading"
:label="phone.t('Common.loading')"
/>
</div>
</section>
</section>
</SkySheet>
<FullEmojiPicker
v-if="activeCanMessage && emojiOpen"