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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Common.back') }}
+ {{ label('nearby') }}
+
+
+
+
+
+
+
+
+
+
+ {{ label('incomingFrom', { name: easyShare.incomingTransfer.otherName }) }}
+ {{ easyShare.incomingTransfer.payload.title }}
+
+
+
+
+
+
+
+
+
+ {{ easyShare.activeTransfer.otherName }}
+ {{ statusLabel(easyShare.activeTransfer) }}
+
+
+ {{ label('cancel') }}
+
+
+
+
+
+
+ {{ label('noNearby') }}
+
+
+
+
+ {{ phone.t('Common.back') }}
+ {{ label('history') }}
+
+
+
+
+
+
+ {{ transfer.payload.title }}
+ {{ transfer.otherName }} ยท {{ statusLabel(transfer) }}
+
+ {{ transfer.direction === 'incoming' ? 'โ' : 'โ' }}
+
+
{{ label('noHistory') }}
+
+
+
+
{{ feedback }}
+
+
+
+
+
+
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') }}
+
+
+
+
@@ -984,7 +1018,11 @@ onBeforeUnmount(() => {
-