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
+64
View File
@@ -6409,6 +6409,62 @@ button {
backdrop-filter: blur(32px) saturate(180%);
-webkit-backdrop-filter: blur(32px) saturate(180%);
}
.messages-media-picker-sheet {
z-index: 90;
--sky-overlay-backdrop: rgb(0 0 0 / 34%);
--sky-sheet-background: rgb(247 247 251 / 96%);
}
.messages-media-picker-sheet .sky-sheet__panel {
height: min(62%, 480px);
max-height: 78%;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid rgb(255 255 255 / 76%);
border-bottom: 0;
border-radius: 30px 30px 0 0;
background: var(--sky-sheet-background);
box-shadow: 0 -14px 38px rgb(20 26 43 / 18%);
backdrop-filter: blur(34px) saturate(180%);
-webkit-backdrop-filter: blur(34px) saturate(180%);
}
.messages-media-picker-sheet .sky-sheet__grabber {
flex: none;
height: 27px;
}
.messages-media-picker-sheet .messages-media-picker {
position: relative;
z-index: auto;
inset: auto;
width: 100%;
height: auto;
min-height: 0;
flex: 1;
border: 0;
background: transparent;
box-shadow: none;
animation: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.messages-media-picker-sheet .messages-media-picker > header {
height: 44px;
padding: 0 18px 0 20px;
border-color: rgb(60 60 67 / 14%);
}
.messages-media-picker-sheet .messages-media-picker > header strong {
font-size: 16px;
line-height: 20px;
}
.messages-media-picker-sheet .messages-media-picker__contacts,
.messages-media-picker-sheet .messages-media-picker__gifs {
height: calc(100% - 44px);
padding-bottom: calc(var(--sky-safe-area-bottom) + 10px);
overscroll-behavior: contain;
}
.messages-media-picker-sheet .messages-media-picker__contacts {
padding-top: 6px;
}
.messages-media-picker__photos,
.messages-media-picker__gifs,
.messages-media-picker__videos {
@@ -6768,6 +6824,14 @@ button {
box-shadow: 0 -10px 34px rgb(0 0 0 / 38%);
color: #f5f5f7;
}
.phone-app.dark .messages-media-picker-sheet {
--sky-overlay-backdrop: rgb(0 0 0 / 52%);
--sky-sheet-background: rgb(28 28 30 / 96%);
}
.phone-app.dark .messages-media-picker-sheet .sky-sheet__panel {
border-color: rgb(255 255 255 / 11%);
box-shadow: 0 -16px 42px rgb(0 0 0 / 46%);
}
.phone-app.dark .messages-gif-search,
.phone-app.dark .messages-full-emoji-picker__search,
.phone-app.dark .messages-full-emoji-picker > header button,
@@ -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"