diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 996483d..140147f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,6 +18,7 @@ import PhonePasscode from '@/components/PhonePasscode.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' +import EasyShareSheet from '@/components/EasyShareSheet.vue' import PayphoneOverlay from '@/components/PayphoneOverlay.vue' import RadioHud from '@/components/RadioHud.vue' import SimPhonePicker, { @@ -44,6 +45,7 @@ import { useWidgetsStore } from '@/stores/widgets' import { isPhoneAppId } from '@/config/apps' import { useNotesStore } from '@/stores/notes' import { useWeatherStore } from '@/stores/weather' +import { useEasyShareStore } from '@/stores/easyshare' import { useNotificationsStore, type PhoneNotificationInput, @@ -53,6 +55,7 @@ import type { PhoneNotificationDevicePayload } from '@/types/device' import type { MailCounts } from '@/types/mail' import type { MarketplaceCounts } from '@/types/marketplace' import type { PhoneCall } from '@/types/phone' +import type { EasyShareEvent } from '@/types/easyshare' import { nuiCall } from '@/utils/nui' import { formatTimer } from '@/utils/clock' import { parsePhonePreferences } from '@/utils/preferences' @@ -73,6 +76,7 @@ type AppMessage = { | PicstagramNotificationData | FeatherNotificationData | BillingNotificationData + | EasyShareEvent | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -222,6 +226,7 @@ const appStore = useAppStoreStore() const widgets = useWidgetsStore() const notes = useNotesStore() const weather = useWeatherStore() +const easyShare = useEasyShareStore() const notifications = useNotificationsStore() const route = useRoute() const router = useRouter() @@ -537,6 +542,8 @@ function onMessage(event: MessageEvent): void { notifications.show(notification) } else if (event.data?.type === 'contacts:changed') { void calls.loadContacts() + } else if (event.data?.type === 'easyshare:changed' && event.data.data) { + easyShare.applyEvent(event.data.data as EasyShareEvent) } else if (event.data?.type === 'messages:changed') { void messages.loadConversations() if (messages.activeNumber) void messages.openThread(messages.activeNumber) @@ -872,8 +879,9 @@ onMounted(() => { } }, 1000) if (isDevelopment) { + const developmentParameters = new URLSearchParams(window.location.search) void hydrateDevelopmentPhone() - if (new URLSearchParams(window.location.search).has('simPickerPreview')) { + if (developmentParameters.has('simPickerPreview')) { simPicker.value = { choices: [ { @@ -1109,6 +1117,7 @@ onBeforeUnmount(() => { :notification="phone.isOpen ? null : notifications.current" @close="notifications.dismissCurrent()" /> + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 0ec360e..d727a51 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -4489,22 +4489,28 @@ button { inset 0 1px 0 rgb(255 255 255 / 30%), 0 3px 12px rgb(0 0 0 / 12%); overflow: hidden; + background: linear-gradient(145deg, #5b91c2, #22527d); color: #fff; + font-size: 19px; + font-weight: 700; + letter-spacing: -0.4px; } -.messages-avatar__glyph { - padding-top: 6px; - font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; - font-size: 34px; +.messages-avatar__initials { line-height: 1; - filter: drop-shadow(0 2px 2px rgb(0 0 0 / 15%)); +} +.messages-avatar__image { + width: 100%; + height: 100%; + display: block; + border-radius: inherit; + object-fit: cover; } .messages-avatar--small { width: 38px; height: 38px; } -.messages-avatar--small .messages-avatar__glyph { - padding-top: 5px; - font-size: 27px; +.messages-avatar--small .messages-avatar__initials { + font-size: 15px; } .messages-empty-state { min-height: 72%; @@ -4618,9 +4624,8 @@ button { 0 0 0 1px rgb(0 0 0 / 5%), 0 8px 22px rgb(0 0 0 / 12%); } -.messages-avatar--header .messages-avatar__glyph { - padding-top: 7px; - font-size: 42px; +.messages-avatar--header .messages-avatar__initials { + font-size: 23px; } .messages-chat-header__name { max-width: 205px; @@ -5266,9 +5271,8 @@ button { width: 78px; height: 78px; } -.messages-avatar--contact .messages-avatar__glyph { - padding-top: 10px; - font-size: 57px; +.messages-avatar--contact .messages-avatar__initials { + font-size: 32px; } .messages-contact-details__hero h2 { margin: 10px 0 2px; @@ -6261,9 +6265,8 @@ button { width: 50px; height: 50px; } -.messages-inbox-page .messages-avatar__glyph { - padding-top: 6px; - font-size: 34px; +.messages-inbox-page .messages-avatar__initials { + font-size: 19px; } .messages-inbox-page .messages-conversation__body { gap: 3px; diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue new file mode 100644 index 0000000..ffc9f94 --- /dev/null +++ b/frontend/src/components/EasyShareSheet.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/frontend/src/stores/easyshare.test.ts b/frontend/src/stores/easyshare.test.ts new file mode 100644 index 0000000..ef63001 --- /dev/null +++ b/frontend/src/stores/easyshare.test.ts @@ -0,0 +1,104 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useEasyShareStore } from '@/stores/easyshare' +import type { EasySharePayload, EasyShareTransfer } from '@/types/easyshare' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const payload: EasySharePayload = { + appId: 'notes', + copyText: 'Meet at Mission Row.', + id: 'note-1', + kind: 'note', + title: 'Meeting', +} +const incoming: EasyShareTransfer = { + createdAt: Date.now(), + direction: 'incoming', + id: 'transfer-1', + otherName: 'Mia Santos', + payload, + progress: 0, + status: 'pending', +} + +describe('easyshare store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('opens with the selected share payload', () => { + const easyShare = useEasyShareStore() + easyShare.open(payload) + + expect(easyShare.opened).toBe(true) + expect(easyShare.payload).toEqual(payload) + expect(easyShare.nearbyOpened).toBe(false) + }) + + it('opens the acceptance view for an incoming request', () => { + const easyShare = useEasyShareStore() + easyShare.applyEvent({ transfer: incoming }) + + expect(easyShare.opened).toBe(true) + expect(easyShare.nearbyOpened).toBe(true) + expect(easyShare.incomingTransfer).toEqual(incoming) + }) + + it('keeps progress server-driven and removes terminal transfers from pending', () => { + const easyShare = useEasyShareStore() + easyShare.applyEvent({ transfer: incoming }) + easyShare.applyEvent({ + transfer: { ...incoming, progress: 50, status: 'transferring' }, + }) + expect(easyShare.pending[0]?.progress).toBe(50) + + easyShare.applyEvent({ + transfer: { ...incoming, progress: 100, status: 'completed' }, + }) + expect(easyShare.pending).toEqual([]) + expect(easyShare.history[0]?.status).toBe('completed') + }) + + it('sends only the target and current payload when requesting a transfer', async () => { + mockNuiCall.mockResolvedValueOnce({ data: incoming, success: true }) + const easyShare = useEasyShareStore() + easyShare.open(payload) + + await easyShare.request(41) + + expect(mockNuiCall).toHaveBeenCalledWith('easyshare:request', { + payload, + targetId: 41, + }) + }) + + it('hands a prepared share message to the selected chat app once', () => { + const easyShare = useEasyShareStore() + easyShare.open({ ...payload, link: 'https://notes.sky/note-1' }) + + expect(easyShare.prepareChatDraft('messages', '5551234567')).toBe(true) + expect(easyShare.consumeChatDraft('messages')).toEqual({ + appId: 'messages', + body: 'Meet at Mission Row.\nhttps://notes.sky/note-1', + targetId: '5551234567', + }) + expect(easyShare.consumeChatDraft('messages')).toBeNull() + }) + + it('keeps a share draft until a conversation is chosen in the destination app', () => { + const easyShare = useEasyShareStore() + easyShare.open(payload) + + expect(easyShare.prepareChatDraft('darkchat')).toBe(true) + expect(easyShare.consumeChatDraft('darkchat')).toEqual({ + appId: 'darkchat', + body: 'Meet at Mission Row.', + targetId: null, + }) + }) +}) diff --git a/frontend/src/stores/easyshare.ts b/frontend/src/stores/easyshare.ts new file mode 100644 index 0000000..770c012 --- /dev/null +++ b/frontend/src/stores/easyshare.ts @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { + EasyShareBootstrap, + EasyShareChatApp, + EasyShareChatDraft, + EasyShareEvent, + EasySharePayload, + EasyShareTarget, + EasyShareTransfer, + EasyShareVisibility, +} from '@/types/easyshare' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useEasyShareStore = defineStore('easyshare', () => { + const opened = ref(false) + const payload = ref(null) + const chatDraft = ref(null) + const targets = ref([]) + const history = ref([]) + const pending = ref([]) + const visibility = ref('everyone') + const loading = ref(false) + const nearbyOpened = ref(false) + const historyOpened = ref(false) + const activeTransfer = ref(null) + const incomingTransfer = computed( + () => pending.value.find((transfer) => transfer.direction === 'incoming') ?? null, + ) + + function open(nextPayload: EasySharePayload): void { + payload.value = nextPayload + nearbyOpened.value = false + historyOpened.value = false + activeTransfer.value = null + opened.value = true + } + + function close(): void { + opened.value = false + nearbyOpened.value = false + historyOpened.value = false + activeTransfer.value = null + payload.value = null + } + + function prepareChatDraft( + appId: EasyShareChatApp, + targetId: string | null = null, + ): boolean { + if (!payload.value) return false + const parts = [payload.value.copyText.trim(), payload.value.link?.trim()].filter( + (part): part is string => Boolean(part), + ) + chatDraft.value = { + appId, + body: [...new Set(parts)].join('\n'), + targetId, + } + return Boolean(chatDraft.value.body) + } + + function consumeChatDraft(appId: EasyShareChatApp): EasyShareChatDraft | null { + if (chatDraft.value?.appId !== appId) return null + const draft = chatDraft.value + chatDraft.value = null + return draft + } + + async function bootstrap(): Promise { + loading.value = true + const response = await nuiCall('easyshare:bootstrap') + loading.value = false + if (!response.success || !response.data) return false + targets.value = response.data.targets + history.value = response.data.history + pending.value = response.data.pending + visibility.value = response.data.visibility + return true + } + + async function showNearby(): Promise { + nearbyOpened.value = true + historyOpened.value = false + await bootstrap() + } + + async function showHistory(): Promise { + historyOpened.value = true + nearbyOpened.value = false + await bootstrap() + } + + async function setVisibility(next: EasyShareVisibility): Promise { + const response = await nuiCall<{ visibility: EasyShareVisibility }>( + 'easyshare:set-visibility', + { visibility: next }, + ) + if (response.success && response.data) visibility.value = response.data.visibility + return response.success + } + + async function request(targetId: number): Promise> { + if (!payload.value) return { success: false, error: 'missing_payload' } + const response = await nuiCall('easyshare:request', { + payload: payload.value, + targetId, + }) + if (response.success && response.data) activeTransfer.value = response.data + return response + } + + async function respond(id: string, accepted: boolean): Promise { + const response = await nuiCall('easyshare:respond', { + accepted, + id, + }) + if (response.success && response.data) applyTransfer(response.data) + return response.success + } + + async function cancel(id: string): Promise { + const response = await nuiCall('easyshare:cancel', { id }) + if (response.success && response.data) applyTransfer(response.data) + return response.success + } + + function applyTransfer(transfer: EasyShareTransfer): void { + const pendingIndex = pending.value.findIndex((entry) => entry.id === transfer.id) + if (['pending', 'transferring'].includes(transfer.status)) { + if (pendingIndex >= 0) pending.value[pendingIndex] = transfer + else pending.value.unshift(transfer) + } else if (pendingIndex >= 0) { + pending.value.splice(pendingIndex, 1) + } + + const historyIndex = history.value.findIndex((entry) => entry.id === transfer.id) + if (historyIndex >= 0) history.value[historyIndex] = transfer + else history.value.unshift(transfer) + history.value = history.value.slice(0, 50) + if (activeTransfer.value?.id === transfer.id) activeTransfer.value = transfer + } + + function applyEvent(event: EasyShareEvent): void { + applyTransfer(event.transfer) + if (event.transfer.direction === 'incoming' && event.transfer.status === 'pending') { + opened.value = true + nearbyOpened.value = true + historyOpened.value = false + } + } + + return { + activeTransfer, + applyEvent, + bootstrap, + cancel, + chatDraft, + close, + consumeChatDraft, + history, + historyOpened, + incomingTransfer, + loading, + nearbyOpened, + open, + opened, + payload, + pending, + prepareChatDraft, + request, + respond, + setVisibility, + showHistory, + showNearby, + targets, + visibility, + } +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 14cb968..dc41171 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -40,6 +40,56 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + easyShare: { + name: 'EasyShare', + incoming: 'Incoming Share', + recentChats: 'Contacts and Chats', + destinations: 'Share destinations', + newMessage: 'New Message', + shareProfile: 'Share Profile', + chooseConversation: 'Choose a conversation', + sentToChat: 'Sent to chat.', + savedToNotes: 'Saved to Notes.', + copy: 'Copy', + copyLink: 'Copy Link', + nearby: 'People Nearby', + history: 'Transfer History', + noHistory: 'No transfers yet.', + noNearby: 'No visible players are nearby.', + visibility: 'Visibility', + requestSent: 'Waiting for acceptance...', + incomingFrom: '{name} wants to share', + distance: '{distance} m away', + cancel: 'Cancel', + visibilityOptions: { + everyone: 'Everyone', + contacts: 'Contacts Only', + hidden: 'Receiving Off', + }, + status: { + pending: 'Waiting for acceptance', + transferring: 'Transferring', + completed: 'Completed', + declined: 'Declined', + cancelled: 'Cancelled', + expired: 'Expired', + failed: 'Failed', + accepted: 'Accepted', + }, + errors: { + disabled: 'EasyShare is disabled.', + invalid_payload: 'This item cannot be shared.', + invalid_target: 'Choose a valid recipient.', + target_unavailable: 'That player is no longer available.', + transfer_not_found: 'That transfer is no longer available.', + too_far: 'The recipient is too far away.', + not_owned: 'This item no longer belongs to this phone.', + payload_too_large: 'This item is too large to share.', + location_unavailable: 'Your location is unavailable.', + rate_limited: 'Too many share requests. Try again shortly.', + request_failed: 'EasyShare is temporarily unavailable.', + }, + }, crewlink: { name: 'CrewLink', connecting: 'Connecting your crew...', diff --git a/frontend/src/types/easyshare.ts b/frontend/src/types/easyshare.ts new file mode 100644 index 0000000..ed63d73 --- /dev/null +++ b/frontend/src/types/easyshare.ts @@ -0,0 +1,71 @@ +import type { LaunchablePhoneAppId } from '@/types/apps' + +export type EasyShareKind = + | 'contact' + | 'document' + | 'link' + | 'location' + | 'note' + | 'photo' + | 'playlist' + | 'post' + | 'profile' + | 'text' + | 'track' + | 'video' + +export type EasyShareVisibility = 'contacts' | 'everyone' | 'hidden' +export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages' +export type EasyShareChatDraft = { + appId: EasyShareChatApp + body: string + targetId: string | null +} +export type EasyShareStatus = + | 'accepted' + | 'cancelled' + | 'completed' + | 'declined' + | 'expired' + | 'failed' + | 'pending' + | 'transferring' + +export type EasySharePayload = { + appId: LaunchablePhoneAppId + copyText: string + id?: number | string + imageUrl?: string | null + kind: EasyShareKind + link?: string + meta?: Record + subtitle?: string + title: string +} + +export type EasyShareTarget = { + distance: number + id: number + name: string +} + +export type EasyShareTransfer = { + createdAt: number + direction: 'incoming' | 'outgoing' + id: string + otherName: string + payload: EasySharePayload + progress: number + status: EasyShareStatus +} + +export type EasyShareBootstrap = { + history: EasyShareTransfer[] + pending: EasyShareTransfer[] + targets: EasyShareTarget[] + visibility: EasyShareVisibility +} + +export type EasyShareEvent = { + transfer: EasyShareTransfer +} diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue index 35ae297..dd90b9c 100644 --- a/frontend/src/views/apps/CrewLinkApp.vue +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -41,6 +41,7 @@ import { RefreshCw, Route, Settings2, + Share2, Shield, ShieldCheck, Sparkles, @@ -69,6 +70,7 @@ import { type MapPoint, } from '@/features/map/defaultMapGeometry' import { useCrewLinkStore } from '@/stores/crewlink' +import { useEasyShareStore } from '@/stores/easyshare' import { usePhoneStore } from '@/stores/phone' import type { CrewLinkColour, @@ -240,6 +242,20 @@ function showToast(message: string): void { }, 2400) } +function shareProfile(): void { + const profile = crew.profile + if (!profile) return + useEasyShareStore().open({ + appId: 'crewlink', + copyText: `@${profile.username}`, + id: profile.id, + kind: 'profile', + link: `skyphone://crewlink/profile/${profile.id}`, + subtitle: activeGroup.value?.name, + title: `@${profile.username}`, + }) +} + function updateValue( target: 'username' | 'groupName' | 'inviteCode' | 'pingLabel', event: Event, @@ -900,6 +916,17 @@ onBeforeUnmount(() => {
{{ t('yourCrewLinkId') }}

@{{ crew.profile.username }}

{{ activeGroup.name }} ยท {{ roleLabel(activeGroup.role) }}

+ + + + + + {{ t('privacyVisibility') }} diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue index 172fdf4..bd91ad0 100644 --- a/frontend/src/views/apps/DarkChatApp.vue +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -37,6 +37,7 @@ import { Plus, QrCode, Reply, + Share2, ShieldCheck, ShieldOff, Trash2, @@ -45,13 +46,14 @@ import { Video, X, } from 'lucide-vue-next' -import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue' import FullEmojiPicker from '@/components/FullEmojiPicker.vue' import { useAccountStore } from '@/stores/account' import { useDarkChatStore } from '@/stores/darkchat' +import { useEasyShareStore } from '@/stores/easyshare' import { useMessagesStore } from '@/stores/messages' import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' @@ -97,6 +99,7 @@ const darkSheetColors = { } const account = useAccountStore() const darkchat = useDarkChatStore() +const easyShare = useEasyShareStore() const sms = useMessagesStore() const messageMedia = useMessageMediaStore() const phone = usePhoneStore() @@ -107,6 +110,7 @@ const identifier = ref('') const pendingIdentifier = ref('') const safetyOpen = ref(false) const draft = ref('') +const queuedShareBody = ref('') const aliasDraft = ref('') const contactAliasDraft = ref('') const notificationMode = ref('private') @@ -326,6 +330,10 @@ async function openConversation(conversationId: string): Promise { } screen.value = 'thread' resetPanels() + if (queuedShareBody.value) { + draft.value = queuedShareBody.value + queuedShareBody.value = '' + } await scrollBottom(false) } @@ -498,6 +506,45 @@ function copyMessage(message: DarkChatMessage): void { showToast(copyText(message.body) ? t('copied') : errorText()) } +async function openEasyShareDraft(): Promise { + const shared = easyShare.consumeChatDraft('darkchat') + if (!shared) return false + if (!shared.targetId) { + darkchat.closeThread() + screen.value = 'inbox' + resetPanels() + queuedShareBody.value = shared.body + draft.value = '' + return true + } + if (!(await darkchat.openThread(shared.targetId))) { + showToast(errorText(darkchat.lastError ?? undefined)) + return true + } + screen.value = 'thread' + resetPanels() + queuedShareBody.value = '' + draft.value = shared.body + await scrollBottom(false) + return true +} + +function shareMessage(message: DarkChatMessage): void { + selectedMessage.value = null + useEasyShareStore().open({ + appId: 'darkchat', + copyText: message.body || message.messageType, + id: message.id, + imageUrl: + message.messageType === 'image' || message.messageType === 'video' + ? message.mediaPayload + : undefined, + kind: 'text', + subtitle: active.value?.peer.alias, + title: message.body || message.messageType, + }) +} + async function react( message: DarkChatMessage, reaction: string, @@ -648,6 +695,20 @@ function chooseSelection(value: number | string): void { selectionSheet.value = null } +function shareIdentity(): void { + const profile = darkchat.profile + if (!profile) return + easyShare.open({ + appId: 'darkchat', + copyText: `${profile.alias}\n${profile.darkId}\n${profile.inviteCode}`, + id: profile.id, + kind: 'profile', + link: `skyphone://darkchat/invite/${profile.inviteCode}`, + subtitle: profile.darkId, + title: profile.alias, + }) +} + function copyIdentity(value: string): void { showToast(copyText(value) ? t('copied') : errorText()) } @@ -818,12 +879,20 @@ function recordingTime(): string { onMounted(async () => { if (signedIn.value) await darkchat.bootstrap() + if (await openEasyShareDraft()) return if (!active.value) return screen.value = 'thread' const media = messageMedia.consume(`darkchat:${active.value.id}`) if (media) await sendAttachment(media) }) +watch( + () => easyShare.chatDraft, + (shared) => { + if (shared?.appId === 'darkchat') void openEasyShareDraft() + }, +) + onBeforeUnmount(() => { discardRecording = true cleanupRecording() @@ -2296,8 +2365,8 @@ onBeforeUnmount(() => { {{ darkchat.profile.inviteCode }}{{ phone.t('Apps.easyShare.shareProfile') }} @@ -2770,6 +2839,14 @@ onBeforeUnmount(() => { + { await runSearch() } -async function shareProfile(): Promise { - if (!activeProfile.value) return - try { - await navigator.clipboard.writeText(`@${activeProfile.value.handle}`) - toast('profileCopied') - } catch (error) { - console.error('[Feather] Could not copy the profile handle.', error) - toast('errors.generic') - } +function shareProfile(): void { + const profile = activeProfile.value + if (!profile) return + useEasyShareStore().open({ + appId: 'feather', + copyText: `@${profile.handle}`, + id: profile.id, + imageUrl: profile.avatar_url, + kind: 'profile', + link: `skyphone://feather/profile/${profile.id}`, + subtitle: `@${profile.handle}`, + title: profile.display_name, + }) } -async function sharePost(post: FeatherPost): Promise { - try { - await navigator.clipboard.writeText(`@${post.handle}: ${post.body}`) - toast('postCopied') - } catch (error) { - console.error('[Feather] Could not copy the post.', error) - toast('errors.generic') - } +function sharePost(post: FeatherPost): void { + useEasyShareStore().open({ + appId: 'feather', + copyText: `@${post.handle}: ${post.body}`, + id: post.id, + imageUrl: post.media[0]?.url, + kind: 'post', + link: `skyphone://feather/post/${post.id}`, + subtitle: `@${post.handle}`, + title: post.body, + }) } function openComposer(post?: FeatherPost): void { diff --git a/frontend/src/views/apps/FlareApp.vue b/frontend/src/views/apps/FlareApp.vue index 473f0fb..fc96f4c 100644 --- a/frontend/src/views/apps/FlareApp.vue +++ b/frontend/src/views/apps/FlareApp.vue @@ -45,6 +45,7 @@ import { RotateCcw, Search, Settings2, + Share2, Star, SlidersHorizontal, UserRound, @@ -59,12 +60,14 @@ import { onMounted, reactive, ref, + watch, } from 'vue' import { useRoute, useRouter } from 'vue-router' import profilesSprite from '@/assets/img/flare/profiles-source.png' import FullEmojiPicker from '@/components/FullEmojiPicker.vue' import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue' +import { useEasyShareStore } from '@/stores/easyshare' import { useFlareStore } from '@/stores/flare' import { useMessageMediaStore } from '@/stores/messageMedia' import { useMessagesStore } from '@/stores/messages' @@ -97,6 +100,7 @@ type FlareMediaContext = { type FlareChatMediaContext = { matchId: string } const phone = usePhoneStore() +const easyShare = useEasyShareStore() const flare = useFlareStore() const messageMedia = useMessageMediaStore() const messages = useMessagesStore() @@ -128,6 +132,21 @@ const gifResults = ref([]) const gifLoading = ref(false) const gifError = ref(null) const gifHasMore = ref(true) + +function shareProfile(): void { + const profile = flare.profile + if (!profile) return + easyShare.open({ + appId: 'flare', + copyText: `${profile.name}, ${profile.age}\n${profile.bio}`, + id: profile.id, + imageUrl: profile.photoUrls[0], + kind: 'profile', + link: `skyphone://flare/profile/${profile.id}`, + subtitle: phone.t(`Apps.flare.lookingFor.${profile.lookingFor}`), + title: `${profile.name}, ${profile.age}`, + }) +} const gifNextOffset = ref(0) const draftPhotos = ref([]) const activeChoiceField = ref('gender') @@ -568,6 +587,19 @@ async function openMatch(match: FlareMatch): Promise { messageScroll.value?.scrollTo({ top: messageScroll.value.scrollHeight }) } +async function openEasyShareDraft(): Promise { + const shared = easyShare.consumeChatDraft('flare') + if (!shared?.targetId) return false + const match = flare.matches.find((item) => item.id === shared.targetId) + if (!match) { + showActionError() + return true + } + await openMatch(match) + if (activeMatch.value?.id === match.id) draft.value = shared.body + return true +} + function closeMatch(): void { activeMatch.value = null draft.value = '' @@ -739,6 +771,7 @@ function messageTime(value: DatabaseDateValue): string { onMounted(async () => { await flare.bootstrap() + await openEasyShareDraft() const selection = messageMedia.consumeMany( 'flare:profile-photos', ) @@ -790,6 +823,13 @@ onMounted(async () => { } }) +watch( + () => easyShare.chatDraft, + (shared) => { + if (shared?.appId === 'flare') void openEasyShareDraft() + }, +) + onBeforeUnmount(() => { if (gifSearchTimer) clearTimeout(gifSearchTimer) }) @@ -1539,6 +1579,10 @@ onBeforeUnmount(() => { {{ phone.t('Apps.flare.editProfile') }} + diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index a0b8434..a6ad07e 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -54,6 +54,7 @@ import { useRouter } from 'vue-router' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import { useFlipTokStore } from '@/stores/fliptok' +import { useEasyShareStore } from '@/stores/easyshare' import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' import type { @@ -604,8 +605,30 @@ function openActions(video: FlipTokVideo): void { async function shareVideo(video: FlipTokVideo): Promise { const response = await nuiCall('fliptok:share', { id: video.id }) if (response.success) video.share_count += 1 - await navigator.clipboard?.writeText(`fliptok://video/${video.id}`) - notify(t('linkCopied')) + useEasyShareStore().open({ + appId: 'fliptok', + copyText: `@${video.handle}: ${video.caption}`, + id: video.id, + imageUrl: video.url, + kind: 'post', + link: `skyphone://fliptok/video/${video.id}`, + subtitle: `@${video.handle}`, + title: video.caption || video.display_name, + }) +} + +function shareCurrentProfile(): void { + const profile = currentProfile.value + if (!profile) return + useEasyShareStore().open({ + appId: 'fliptok', + copyText: `@${profile.handle}`, + id: profile.id, + kind: 'profile', + link: `skyphone://fliptok/profile/${profile.id}`, + subtitle: `@${profile.handle}`, + title: profile.display_name, + }) } async function reportVideo(): Promise { @@ -1160,6 +1183,10 @@ onBeforeUnmount(() => { >{{ t('block') }} + + + {{ phone.t('Apps.easyShare.shareProfile') }} +