Files
sky_phone/frontend/src/components/EasyShareSheet.vue
T
Leon.Schmidt 332060b8e3 ENH - modernize FlipTok and Messages
Replace the mixed legacy Konsta presentation and fixed light surfaces with the shared Sky UI system. Add FlipTok comment replies and reactions, editable profile media, follower lists, external sound metadata, and the required schema migration.
2026-08-14 01:28:48 +02:00

938 lines
24 KiB
Vue

<script setup lang="ts">
import {
Check,
Clock3,
History,
MessageCircle,
Share2,
UserRound,
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { useCallsStore } from '@/stores/calls'
import { useDarkChatStore } from '@/stores/darkchat'
import { useEasyShareStore } from '@/stores/easyshare'
import { useFlareStore } from '@/stores/flare'
import { useMessagesStore } from '@/stores/messages'
import { useNotesStore } from '@/stores/notes'
import { usePhoneStore } from '@/stores/phone'
import type {
EasyShareChatApp,
EasyShareDestinationApp,
EasyShareTransfer,
} from '@/types/easyshare'
import {
easyShareDestinationAppIds,
openEasySharePayload,
} from '@/utils/easyshare'
import {
SkyButton,
SkyGlass,
SkyLink,
SkyList,
SkyListItem,
SkySheet,
SkySpinner,
} from '@/ui'
import { consumeEscape } from '@/utils/keyboard'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const calls = useCallsStore()
const darkChat = useDarkChatStore()
const messages = useMessagesStore()
const notes = useNotesStore()
const easyShare = useEasyShareStore()
const flare = useFlareStore()
const router = useRouter()
const feedback = ref('')
const dragOffset = ref(0)
const dragging = ref(false)
let dragPointerId: number | null = null
let dragStartTime = 0
let dragStartY = 0
const app = computed(() =>
easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined,
)
const sharePeople = computed(() => {
const people: Array<{
avatar?: string
kind: EasyShareChatApp
label: string
targetId: string
}> = []
const phoneNumbers = new Set<string>()
if (!appStore.homeLayout.hidden.includes('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
kind: 'flare',
label: match.profile.name,
targetId: match.id,
})
}
}
for (const conversation of messages.conversations.slice(0, 8)) {
const contact = calls.contacts.find(
(entry) => entry.phone_number === conversation.phoneNumber,
)
people.push({
avatar: contact?.avatar_url ?? undefined,
label: contact?.name ?? conversation.phoneNumber,
kind: 'messages' as const,
targetId: conversation.phoneNumber,
})
phoneNumbers.add(conversation.phoneNumber)
}
for (const contact of calls.contacts) {
if (phoneNumbers.has(contact.phone_number)) continue
people.push({
avatar: contact.avatar_url ?? undefined,
kind: 'messages',
label: contact.name,
targetId: contact.phone_number,
})
phoneNumbers.add(contact.phone_number)
}
if (!appStore.homeLayout.hidden.includes('darkchat')) {
for (const conversation of darkChat.conversations.slice(0, 8)) {
people.push({
kind: 'darkchat',
label: conversation.peer.alias,
targetId: conversation.id,
})
}
}
return people
})
const shareApps = computed(() =>
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
.filter((id) => !appStore.homeLayout.hidden.includes(id))
.flatMap((id) => {
const app = getPhoneApp(id)
return app ? [{ app, id }] : []
}),
)
const hostStyle = computed(() => ({
'--easyshare-drag-offset': `${dragOffset.value}px`,
}))
function label(key: string, params?: Record<string, string>): string {
return phone.t(`Apps.easyShare.${key}`, params)
}
function initials(value: string): string {
return value
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
function statusLabel(transfer: EasyShareTransfer): string {
return label(`status.${transfer.status}`)
}
function scrollRowHorizontally(event: WheelEvent): void {
const row = event.currentTarget as HTMLElement
const maximum = row.scrollWidth - row.clientWidth
if (maximum <= 0) return
const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY
const next = Math.min(maximum, Math.max(0, row.scrollLeft + delta))
if (next === row.scrollLeft) return
event.preventDefault()
row.scrollLeft = next
}
function close(): void {
feedback.value = ''
easyShare.close()
}
function onKeydown(event: KeyboardEvent): void {
if (!easyShare.opened || !consumeEscape(event)) return
close()
}
function beginDrag(event: PointerEvent): void {
if (!easyShare.opened || event.button !== 0) return
dragPointerId = event.pointerId
dragStartTime = performance.now()
dragStartY = event.clientY
dragOffset.value = 0
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function moveDrag(event: PointerEvent): void {
if (!dragging.value || event.pointerId !== dragPointerId) return
dragOffset.value = Math.max(0, event.clientY - dragStartY)
}
function endDrag(event: PointerEvent): void {
if (!dragging.value || event.pointerId !== dragPointerId) return
const elapsed = Math.max(1, performance.now() - dragStartTime)
const shouldClose =
dragOffset.value >= 72 ||
(dragOffset.value >= 24 && dragOffset.value / elapsed >= 0.55)
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
dragging.value = false
dragPointerId = null
if (shouldClose) close()
dragOffset.value = 0
}
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
if (!easyShare.prepareChatDraft(kind, targetId)) return
close()
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: EasyShareChatApp): void {
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationApp): void {
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
}
if (appId === 'notes') saveAsNote()
}
function saveAsNote(): void {
if (!easyShare.payload) return
const body = [
easyShare.payload.copyText.trim(),
easyShare.payload.link?.trim(),
]
.filter((part): part is string => Boolean(part))
.filter((part, index, parts) => parts.indexOf(part) === index)
.join('\n')
notes.createNote({
body,
title: easyShare.payload.title,
})
feedback.value = label('savedToNotes')
window.setTimeout(close, 650)
}
async function requestTransfer(targetId: number): Promise<void> {
const response = await easyShare.request(targetId)
feedback.value = response.success
? label('requestSent')
: label(`errors.${response.error ?? 'request_failed'}`)
}
async function respond(
transfer: EasyShareTransfer,
accepted: boolean,
): Promise<void> {
if (!(await easyShare.respond(transfer.id, accepted))) {
feedback.value = label('errors.request_failed')
}
}
async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
if (!(await easyShare.cancel(transfer.id))) {
feedback.value = label('errors.request_failed')
}
}
async function openTransfer(transfer: EasyShareTransfer): Promise<void> {
close()
await openEasySharePayload(router, transfer.payload)
}
onMounted(() => window.addEventListener('keydown', onKeydown, true))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true))
</script>
<template>
<div
class="easyshare-host"
:class="{
'easyshare-host--dragging': dragging,
'easyshare-host--opened': easyShare.opened,
}"
:style="hostStyle"
>
<SkySheet
:opened="easyShare.opened"
:aria-label="label('name')"
@backdropclick="close"
@escape="close"
>
<div class="easyshare-panel">
<div
class="easyshare-grabber"
role="button"
tabindex="0"
:aria-label="phone.t('Common.close')"
@pointercancel="endDrag"
@pointerdown="beginDrag"
@pointermove="moveDrag"
@pointerup="endDrag"
></div>
<header class="easyshare-header">
<img v-if="app?.iconImage" :src="app.iconImage" alt="" />
<span v-else class="easyshare-header__fallback"><Share2 /></span>
<div>
<small>{{
app ? getPhoneAppLabel(app, phone.t) : label('name')
}}</small>
<strong>{{ easyShare.payload?.title ?? label('incoming') }}</strong>
<p>
{{ easyShare.payload?.subtitle || easyShare.payload?.copyText }}
</p>
</div>
</header>
<template v-if="!easyShare.nearbyOpened && !easyShare.historyOpened">
<section
class="easyshare-row easyshare-row--people"
:aria-label="label('recentChats')"
@wheel="scrollRowHorizontally"
>
<button
v-for="chat in sharePeople"
:key="`${chat.kind}:${chat.targetId}`"
type="button"
class="easyshare-person"
@click="shareToChat(chat.kind, chat.targetId)"
>
<span class="easyshare-avatar">
<img v-if="chat.avatar" :src="chat.avatar" alt="" />
<b v-else>{{ initials(chat.label) }}</b>
<i><img :src="getPhoneApp(chat.kind)?.iconImage" alt="" /></i>
</span>
<small>{{ chat.label }}</small>
</button>
<button
v-if="!sharePeople.length"
type="button"
class="easyshare-person"
@click="openChatApp('messages')"
>
<span class="easyshare-avatar easyshare-avatar--empty"
><MessageCircle
/></span>
<small>{{ label('newMessage') }}</small>
</button>
</section>
<section
class="easyshare-row easyshare-row--actions"
:aria-label="label('destinations')"
@wheel="scrollRowHorizontally"
>
<button
type="button"
class="easyshare-action"
@click="easyShare.showNearby"
>
<span
class="easyshare-action__icon easyshare-action__icon--nearby"
aria-hidden="true"
></span>
<small>{{ label('name') }}</small>
</button>
<button
v-for="destination in shareApps"
:key="destination.id"
type="button"
class="easyshare-action"
@click="openShareApp(destination.id)"
>
<span class="easyshare-action__app"
><img :src="destination.app.iconImage" alt=""
/></span>
<small>{{ getPhoneAppLabel(destination.app, phone.t) }}</small>
</button>
<button
type="button"
class="easyshare-action"
@click="easyShare.showHistory"
>
<span class="easyshare-action__icon"><History /></span>
<small>{{ label('history') }}</small>
</button>
</section>
</template>
<section v-else-if="easyShare.nearbyOpened" class="easyshare-detail">
<div class="easyshare-detail__toolbar">
<span aria-hidden="true"></span>
<strong>{{ label('nearby') }}</strong>
<SkyLink component="button" icon-only @click="easyShare.showHistory"
><History :size="18"
/></SkyLink>
</div>
<SkyGlass
v-if="easyShare.incomingTransfer"
class="easyshare-incoming"
>
<UserRound :size="28" />
<div>
<strong>{{
label('incomingFrom', {
name: easyShare.incomingTransfer.otherName,
})
}}</strong>
<small>{{ easyShare.incomingTransfer.payload.title }}</small>
</div>
<div class="easyshare-incoming__actions">
<SkyButton
rounded
tonal
@click="respond(easyShare.incomingTransfer, false)"
><X
/></SkyButton>
<SkyButton
rounded
@click="respond(easyShare.incomingTransfer, true)"
><Check
/></SkyButton>
</div>
</SkyGlass>
<SkyGlass v-if="easyShare.activeTransfer" class="easyshare-transfer">
<div>
<strong>{{ easyShare.activeTransfer.otherName }}</strong>
<small>{{ statusLabel(easyShare.activeTransfer) }}</small>
</div>
<div class="easyshare-progress">
<i
:style="{ width: `${easyShare.activeTransfer.progress}%` }"
></i>
</div>
<SkyButton
v-if="
['pending', 'transferring'].includes(
easyShare.activeTransfer.status,
)
"
clear
rounded
@click="cancelTransfer(easyShare.activeTransfer)"
>{{ label('cancel') }}</SkyButton
>
</SkyGlass>
<div v-if="easyShare.loading" class="easyshare-loading">
<SkySpinner :label="label('name')" />
</div>
<SkyList
v-else-if="easyShare.targets.length"
inset
strong
class="easyshare-targets"
>
<SkyListItem
v-for="target in easyShare.targets"
:key="target.id"
link
:title="target.name"
:subtitle="
label('distance', { distance: String(target.distance) })
"
@click="requestTransfer(target.id)"
>
<template #media
><span><UserRound /></span
></template>
<template #after><Share2 :size="18" /></template>
</SkyListItem>
</SkyList>
<p v-else class="easyshare-empty">{{ label('noNearby') }}</p>
</section>
<section v-else class="easyshare-detail">
<div class="easyshare-detail__toolbar">
<SkyLink
component="button"
@click="easyShare.historyOpened = false"
>{{ phone.t('Common.back') }}</SkyLink
>
<strong>{{ label('history') }}</strong>
<span></span>
</div>
<SkyList inset strong class="easyshare-history">
<SkyListItem
v-for="transfer in easyShare.history"
:key="transfer.id"
link
:title="transfer.payload.title"
:subtitle="`${transfer.otherName} · ${statusLabel(transfer)}`"
@click="openTransfer(transfer)"
>
<template #media
><span><Clock3 /></span
></template>
<template #after>{{
transfer.direction === 'incoming' ? '↓' : '↑'
}}</template>
</SkyListItem>
<p v-if="!easyShare.history.length" class="easyshare-empty">
{{ label('noHistory') }}
</p>
</SkyList>
</section>
<p v-if="feedback" class="easyshare-feedback">{{ feedback }}</p>
</div>
</SkySheet>
</div>
</template>
<style scoped>
.easyshare-host {
--easyshare-solid-surface: #1c1c1d;
position: absolute;
z-index: 95;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
transform: translateZ(0);
}
:global(.phone-app--light) .easyshare-host {
--easyshare-solid-surface: #fff;
}
.easyshare-host--opened {
pointer-events: auto;
}
.easyshare-panel {
position: relative;
max-height: 78cqh;
overflow: hidden;
padding: 8px 14px calc(18px + var(--sky-safe-area-bottom));
border-radius: var(--sky-radius-sheet) var(--sky-radius-sheet) 0 0;
color: var(--sky-text);
background: var(--easyshare-solid-surface) !important;
}
.easyshare-grabber {
width: 38px;
height: 5px;
margin: 0 auto 10px;
border-radius: 999px;
background: var(--sky-muted);
opacity: 0.55;
cursor: grab;
touch-action: none;
}
.easyshare-grabber:active {
cursor: grabbing;
}
.easyshare-header {
display: grid;
grid-template-columns: 54px 1fr;
align-items: center;
gap: 11px;
padding: 5px 2px 14px;
border-bottom: 1px solid var(--sky-hairline);
}
.easyshare-header > img,
.easyshare-header__fallback {
width: 54px;
height: 54px;
border-radius: 14px;
display: grid;
place-items: center;
object-fit: cover;
background: var(--sky-app-accent, #0a84ff);
color: white;
}
.easyshare-header div {
min-width: 0;
}
.easyshare-header small,
.easyshare-header strong,
.easyshare-header p {
display: block;
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.easyshare-header small {
font-size: 11px;
color: var(--sky-muted);
}
.easyshare-header strong {
font-size: 17px;
}
.easyshare-header p {
margin-top: 2px;
font-size: 12px;
color: var(--sky-muted);
}
.easyshare-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 16px 2px;
scrollbar-width: none;
}
.easyshare-row::-webkit-scrollbar {
display: none;
}
.easyshare-row + .easyshare-row {
border-top: 1px solid var(--sky-hairline);
}
.easyshare-row--people {
min-height: 114px;
align-items: flex-start;
}
.easyshare-person,
.easyshare-action {
width: 68px;
flex: 0 0 68px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
color: inherit;
text-align: center;
}
.easyshare-person {
min-height: 98px;
gap: 14px;
}
.easyshare-person small,
.easyshare-action small {
position: relative;
z-index: 3;
display: -webkit-box;
width: 72px;
max-height: 26px;
min-height: 26px;
overflow: hidden;
font-size: 11px;
line-height: 13px;
text-overflow: ellipsis;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.easyshare-avatar {
position: relative;
isolation: isolate;
width: 58px;
height: 58px;
flex: 0 0 58px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: visible;
background: linear-gradient(145deg, #6e6e73, #2c2c2e);
color: white;
}
.easyshare-avatar > img {
position: absolute;
z-index: 0;
inset: 0;
display: block;
width: 58px;
height: 58px;
max-width: none;
border-radius: 50%;
clip-path: circle(50% at 50% 50%);
object-fit: cover;
}
.easyshare-avatar > b {
position: relative;
z-index: 1;
font-size: 17px;
}
.easyshare-avatar > i {
position: absolute;
z-index: 2;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
border: 2px solid var(--easyshare-solid-surface);
border-radius: 7px;
display: grid;
place-items: center;
overflow: hidden;
background: #34c759;
color: white;
}
.easyshare-avatar > i img {
width: 100%;
height: 100%;
object-fit: cover;
}
.easyshare-action__icon,
.easyshare-action__app {
width: 57px;
height: 57px;
border-radius: 15px;
display: grid;
place-items: center;
background: var(--sky-surface-variant);
}
.easyshare-action__icon svg {
width: 27px;
}
.easyshare-action__icon--nearby {
background: radial-gradient(
circle at 50% 70%,
#0a84ff 0 15%,
#142d59 16% 29%,
#0a84ff 30% 35%,
#101722 36%
);
color: white;
}
.easyshare-action__app img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
}
.easyshare-detail {
min-height: 245px;
max-height: 56cqh;
overflow-y: auto;
padding-top: 8px;
}
.easyshare-detail__toolbar {
height: 37px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.easyshare-detail__toolbar > :last-child {
justify-self: end;
}
.easyshare-visibility {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 12px;
border-radius: 14px;
background: var(--sky-surface-variant);
}
.easyshare-visibility select {
min-width: 0;
flex: 1;
border: 0;
color: inherit;
background: transparent;
text-align: right;
}
.easyshare-incoming,
.easyshare-transfer {
display: grid !important;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 12px !important;
border-radius: 18px !important;
}
.easyshare-incoming div,
.easyshare-transfer div {
min-width: 0;
}
.easyshare-incoming small,
.easyshare-transfer small {
display: block;
color: var(--sky-muted);
}
.easyshare-incoming__actions {
grid-column: 1/-1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.easyshare-incoming__actions :deep(.sky-button) {
margin: 0;
}
.easyshare-transfer {
grid-template-columns: 1fr auto;
}
.easyshare-progress {
grid-column: 1/-1;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--sky-surface-variant);
}
.easyshare-progress i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--sky-accent);
transition: width 0.25s ease;
}
.easyshare-targets {
display: grid;
gap: 7px;
}
.easyshare-targets button,
.easyshare-history article {
display: grid;
grid-template-columns: 42px 1fr auto;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 14px;
color: inherit;
background: var(--sky-surface-variant);
text-align: left;
}
.easyshare-targets button > span,
.easyshare-history article > span {
width: 39px;
height: 39px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--sky-accent);
color: white;
}
.easyshare-targets strong,
.easyshare-targets small,
.easyshare-history strong,
.easyshare-history small {
display: block;
}
.easyshare-targets small,
.easyshare-history small {
color: var(--sky-muted);
font-size: 11px;
}
.easyshare-history {
display: grid;
gap: 7px;
padding-top: 8px;
}
.easyshare-empty,
.easyshare-loading {
padding: 35px 10px;
text-align: center;
color: var(--sky-muted);
}
.easyshare-feedback {
margin: 8px 0 0;
text-align: center;
color: var(--sky-accent);
font-size: 12px;
}
.easyshare-host :deep(.sky-sheet) {
z-index: 9500;
}
.easyshare-host :deep(.sky-sheet__panel) {
max-height: 78cqh;
overflow: hidden;
background: var(--easyshare-solid-surface) !important;
}
.easyshare-host--dragging :deep(.sky-sheet__panel) {
transform: translateY(var(--easyshare-drag-offset, 0px));
transition-duration: 0ms;
}
.easyshare-panel {
max-height: 645px;
}
.easyshare-detail {
max-height: 463px;
}
.easyshare-targets button > div,
.easyshare-history article > div {
min-width: 0;
}
.easyshare-targets strong,
.easyshare-targets small,
.easyshare-history strong,
.easyshare-history small {
overflow-wrap: anywhere;
}
.easyshare-host {
--easyshare-list-surface: #2c2c2e;
--easyshare-text: #f5f5f7;
--easyshare-secondary: #a1a1a6;
}
:global(.phone-app--light) .easyshare-host {
--easyshare-list-surface: #f2f2f7;
--easyshare-text: #171719;
--easyshare-secondary: #6e6e73;
}
.easyshare-panel {
color: var(--easyshare-text);
}
.easyshare-history {
overflow: hidden;
background: var(--easyshare-list-surface) !important;
color: var(--easyshare-text) !important;
}
.easyshare-history :deep(.sky-list-item),
.easyshare-history :deep(.sky-list-item__row),
.easyshare-history :deep(.sky-list-item__title) {
background: transparent !important;
color: var(--easyshare-text) !important;
}
.easyshare-history :deep(.sky-list-item__subtitle),
.easyshare-history :deep(.sky-list-item__after),
.easyshare-history :deep(.sky-list-item__chevron) {
color: var(--easyshare-secondary) !important;
}
@supports (container-type: size) {
.easyshare-panel {
max-height: 78cqh;
}
.easyshare-detail {
max-height: 56cqh;
}
}
.easyshare-action__icon--nearby {
position: relative;
overflow: hidden;
background: #0c1524;
box-shadow: inset 0 0 0 1px #ffffff0a;
}
.easyshare-action__icon--nearby::before {
position: absolute;
inset: 8px;
border: 3px solid #1687ff;
border-radius: 50%;
box-shadow:
inset 0 0 0 7px #0c1524,
inset 0 0 0 10px #1687ff;
content: '';
}
.easyshare-action__icon--nearby::after {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
border-radius: 50%;
background: #1687ff;
box-shadow: 0 0 9px #1687ff;
content: '';
transform: translate(-50%, -50%);
}
</style>