ADD - implement EasyShare system

This commit is contained in:
smx.pusha
2026-08-11 14:57:33 +02:00
parent 67edb404c6
commit 7d146e709a
27 changed files with 2067 additions and 87 deletions
+10 -1
View File
@@ -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<AppMessage>): 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()"
/>
<EasyShareSheet />
<div class="phone-display-dimmer" aria-hidden="true"></div>
</k-app>
</div>
+20 -17
View File
@@ -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;
File diff suppressed because one or more lines are too long
+104
View File
@@ -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,
})
})
})
+180
View File
@@ -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<EasySharePayload | null>(null)
const chatDraft = ref<EasyShareChatDraft | null>(null)
const targets = ref<EasyShareTarget[]>([])
const history = ref<EasyShareTransfer[]>([])
const pending = ref<EasyShareTransfer[]>([])
const visibility = ref<EasyShareVisibility>('everyone')
const loading = ref(false)
const nearbyOpened = ref(false)
const historyOpened = ref(false)
const activeTransfer = ref<EasyShareTransfer | null>(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<boolean> {
loading.value = true
const response = await nuiCall<EasyShareBootstrap>('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<void> {
nearbyOpened.value = true
historyOpened.value = false
await bootstrap()
}
async function showHistory(): Promise<void> {
historyOpened.value = true
nearbyOpened.value = false
await bootstrap()
}
async function setVisibility(next: EasyShareVisibility): Promise<boolean> {
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<NuiResponse<EasyShareTransfer>> {
if (!payload.value) return { success: false, error: 'missing_payload' }
const response = await nuiCall<EasyShareTransfer>('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<boolean> {
const response = await nuiCall<EasyShareTransfer>('easyshare:respond', {
accepted,
id,
})
if (response.success && response.data) applyTransfer(response.data)
return response.success
}
async function cancel(id: string): Promise<boolean> {
const response = await nuiCall<EasyShareTransfer>('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,
}
})
+50
View File
@@ -40,6 +40,56 @@ const namespaceQueues = new Map<string, Promise<void>>()
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...',
+71
View File
@@ -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<string, unknown>
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
}
+27
View File
@@ -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(() => {
<div><small>{{ t('yourCrewLinkId') }}</small><h1>@{{ crew.profile.username }}</h1><p>{{ activeGroup.name }} · {{ roleLabel(activeGroup.role) }}</p></div>
</div>
<k-list inset strong>
<k-list-item
link
link-component="button"
:title="phone.t('Apps.easyShare.shareProfile')"
@click="shareProfile"
>
<template #media><Share2 :size="20" /></template>
</k-list-item>
</k-list>
<k-block-title>{{ t('privacyVisibility') }}</k-block-title>
<k-list inset strong>
<k-list-item :title="t('shareOnMap')" :subtitle="t('shareOnMapBody')">
+80 -3
View File
@@ -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<DarkChatNotificationMode>('private')
@@ -326,6 +330,10 @@ async function openConversation(conversationId: string): Promise<void> {
}
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<boolean> {
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(() => {
<k-button
tonal
rounded
@click="copyIdentity(darkchat.profile.inviteCode)"
><Copy :size="16" />{{ darkchat.profile.inviteCode }}</k-button
@click="shareIdentity"
><Share2 :size="16" />{{ phone.t('Apps.easyShare.shareProfile') }}</k-button
>
</k-card>
</div>
@@ -2770,6 +2839,14 @@ onBeforeUnmount(() => {
</button>
</div>
<k-list inset strong class="dc-action-list">
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Apps.easyShare.name')"
@click="shareMessage(selectedMessage)"
><template #media><Share2 :size="20" /></template
></k-list-item>
<k-list-item
link
link-component="button"
+25 -17
View File
@@ -61,6 +61,7 @@ import { useRoute, useRouter } from 'vue-router'
import FeatherPostCard from '@/components/feather/FeatherPostCard.vue'
import { useAccountStore } from '@/stores/account'
import { useFeatherStore } from '@/stores/feather'
import { useEasyShareStore } from '@/stores/easyshare'
import type { FeatherConnectionMode } from '@/stores/feather'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
@@ -417,25 +418,32 @@ async function selectTopic(topic: string): Promise<void> {
await runSearch()
}
async function shareProfile(): Promise<void> {
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<void> {
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 {
+44
View File
@@ -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<GifSearchResult[]>([])
const gifLoading = ref(false)
const gifError = ref<string | null>(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<FlareDraftPhoto[]>([])
const activeChoiceField = ref<FlareChoiceField>('gender')
@@ -568,6 +587,19 @@ async function openMatch(match: FlareMatch): Promise<void> {
messageScroll.value?.scrollTo({ top: messageScroll.value.scrollHeight })
}
async function openEasyShareDraft(): Promise<boolean> {
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<FlareMediaContext>(
'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(() => {
<span><Pencil /></span>
{{ phone.t('Apps.flare.editProfile') }}
</button>
<button type="button" @click="shareProfile">
<span><Share2 /></span>
{{ phone.t('Apps.easyShare.shareProfile') }}
</button>
</div>
<k-card :content-wrap="false" class="flare-profile-card">
<span><Heart fill="currentColor" /></span>
+29 -2
View File
@@ -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<void> {
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<void> {
@@ -1160,6 +1183,10 @@ onBeforeUnmount(() => {
>{{ t('block') }}</k-button
>
</template>
<k-button rounded tonal @click="shareCurrentProfile">
<Share2 :size="17" />
{{ phone.t('Apps.easyShare.shareProfile') }}
</k-button>
</div>
<div class="profile-video-grid">
<button
+31 -1
View File
@@ -12,12 +12,13 @@ import {
kSegmentedButton,
kToast,
} from 'konsta/vue'
import { Play, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
import { Play, RotateCcw, Share2, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { useEasyShareStore } from '@/stores/easyshare'
import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
import {
hasNextMediaPage,
@@ -37,6 +38,7 @@ const filterItems = [
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
@@ -255,6 +257,21 @@ function closeMedia(): void {
stopDragging()
}
function shareSelected(): void {
if (!selected.value) return
const mediaKind = selected.value.mediaType
easyShare.open({
appId: 'photos',
copyText: selected.value.url,
id: selected.value.id,
imageUrl: selected.value.url,
kind: mediaKind,
link: `skyphone://media/${selected.value.id}`,
subtitle: formatDate(selected.value.createdAt),
title: phone.t(mediaKind === 'video' ? 'Apps.photos.video' : 'Apps.photos.photo'),
})
}
function orientToMedia(event: Event): void {
const mediaElement = event.currentTarget as
| HTMLImageElement
@@ -332,6 +349,10 @@ async function deleteSelected(): Promise<void> {
function onMessage(event: MessageEvent): void {
const message = event.data as { data?: DeleteResult; type?: string }
if (message.type === 'gallery:changed') {
void loadGallery()
return
}
if (
message.type !== 'media:deleteResult' ||
message.data?.correlationId !== pendingDeleteCorrelation
@@ -519,6 +540,15 @@ onBeforeUnmount(() => {
v-else
component="button"
icon-only
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareSelected"
>
<Share2 :size="20" />
</k-link>
<k-link
v-if="!requestedMessageMedia"
component="button"
icon-only
class="text-red-500"
:aria-label="phone.t('Apps.photos.delete')"
:disabled="deleting"
+25
View File
@@ -17,6 +17,7 @@ import {
MapPinPlus,
Route,
Satellite,
Share2,
Trash2,
X,
} from 'lucide-vue-next'
@@ -32,6 +33,7 @@ import {
type MapPoint,
} from '@/features/map/defaultMapGeometry'
import { useMapStore } from '@/stores/map'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MapMarker, MapMarkerColor } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
@@ -40,6 +42,7 @@ type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
const phone = usePhoneStore()
const mapStore = useMapStore()
const easyShare = useEasyShareStore()
const mapStyle = ref<MapStyle>('default')
const zoom = ref(1.1)
const pan = ref<MapPoint>({ x: 0, y: 0 })
@@ -462,6 +465,16 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
}
}
function shareCurrentLocation(): void {
easyShare.open({
appId: 'map',
copyText: phone.t('Apps.map.currentLocation'),
kind: 'location',
link: 'skyphone://location/current',
title: phone.t('Apps.map.currentLocation'),
})
}
onMounted(() => {
void loadCurrentLocation(false)
void mapStore.load()
@@ -552,6 +565,18 @@ onBeforeUnmount(() => {
</div>
<nav class="map-controls" :aria-label="phone.t('Apps.map.controls')">
<k-fab
component="button"
type="button"
class="map-control map-control--share"
:colors="locationControlColors"
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareCurrentLocation"
>
<template #icon>
<Share2 aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
+88 -33
View File
@@ -39,13 +39,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 MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
import { useCallsStore } from '@/stores/calls'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMessagesStore } from '@/stores/messages'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
@@ -63,6 +64,7 @@ const WAVEFORM_SAMPLES = 48
const phone = usePhoneStore()
const calls = useCallsStore()
const easyShare = useEasyShareStore()
const messages = useMessagesStore()
const messageMedia = useMessageMediaStore()
const router = useRouter()
@@ -72,6 +74,7 @@ const editingList = ref(false)
const selectedNumbers = ref<string[]>([])
const composerNumber = ref('')
const draft = ref('')
const queuedShareBody = ref('')
const composing = ref(false)
const sending = ref(false)
const toastOpened = ref(false)
@@ -119,6 +122,14 @@ const filteredConversations = computed(() => {
const knownContactNumbers = computed(
() => new Set(calls.contacts.map((contact) => contact.phone_number)),
)
const contactAvatarUrls = computed(
() =>
new Map(
calls.contacts
.filter((contact) => Boolean(contact.avatar_url))
.map((contact) => [contact.phone_number, contact.avatar_url as string]),
),
)
const contactSuggestions = computed(() => {
const query = composerNumber.value.trim().toLocaleLowerCase(phone.lang)
if (!query) return calls.contacts.slice(0, 8)
@@ -163,21 +174,17 @@ function conversationPreview(conversation: SmsConversation): string {
: conversation.lastMessage
}
function avatarStyle(number: string): { background: string } {
let hash = 0
for (const character of number) {
hash = (hash * 31 + character.charCodeAt(0)) % 360
}
return {
background: `linear-gradient(145deg, hsl(${hash} 72% 62%), hsl(${(hash + 35) % 360} 68% 48%))`,
}
}
function avatarGlyph(number: string): string {
const glyphs = ['👩🏻', '🧔🏻', '👩🏽', '👨🏼', '👩🏼', '🧑🏾', '👨🏽', '👩🏾']
let hash = 0
for (const character of number) hash += character.charCodeAt(0)
return glyphs[hash % glyphs.length]
function contactInitials(number: string): string {
const contact = calls.contacts.find(
(entry) => entry.phone_number === number,
)
if (!contact) return ''
return contact.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('')
}
function formatConversationDate(value: DatabaseDateValue): string {
@@ -319,7 +326,28 @@ async function openConversation(conversation: SmsConversation): Promise<void> {
return
}
composing.value = false
draft.value = ''
draft.value = queuedShareBody.value
queuedShareBody.value = ''
await scrollToBottom(false)
}
async function openEasyShareDraft(): Promise<void> {
const shared = easyShare.consumeChatDraft('messages')
if (!shared) return
if (!shared.targetId) {
messages.closeThread()
composing.value = false
queuedShareBody.value = shared.body
draft.value = ''
return
}
if (!(await messages.openThread(shared.targetId))) {
showToast(errorText('invalid_number'))
return
}
composing.value = false
queuedShareBody.value = ''
draft.value = shared.body
await scrollToBottom(false)
}
@@ -682,9 +710,9 @@ async function finishVoiceRecording(): Promise<void> {
await scrollToBottom()
}
onMounted(() => {
void messages.loadConversations()
void calls.loadContacts()
onMounted(async () => {
await Promise.all([messages.loadConversations(), calls.loadContacts()])
await openEasyShareDraft()
if (messages.activeNumber) {
const media = messageMedia.consume(messages.activeNumber)
if (media) {
@@ -696,6 +724,13 @@ onMounted(() => {
}
})
watch(
() => easyShare.chatDraft,
(shared) => {
if (shared?.appId === 'messages') void openEasyShareDraft()
},
)
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
@@ -797,14 +832,19 @@ onBeforeUnmount(() => {
conversation.phoneNumber,
),
}"
:style="avatarStyle(conversation.phoneNumber)"
aria-hidden="true"
>
<img
v-if="contactAvatarUrls.get(conversation.phoneNumber)"
class="messages-avatar__image"
:src="contactAvatarUrls.get(conversation.phoneNumber)"
alt=""
/>
<span
v-if="knownContactNumbers.has(conversation.phoneNumber)"
class="messages-avatar__glyph"
v-else-if="knownContactNumbers.has(conversation.phoneNumber)"
class="messages-avatar__initials"
>
{{ avatarGlyph(conversation.phoneNumber) }}
{{ contactInitials(conversation.phoneNumber) }}
</span>
<span v-else class="messages-avatar__placeholder">
<i />
@@ -937,10 +977,15 @@ onBeforeUnmount(() => {
<template #media>
<span
class="messages-avatar messages-avatar--small"
:style="avatarStyle(contact.phone_number)"
>
<span class="messages-avatar__glyph">{{
avatarGlyph(contact.phone_number)
<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>
@@ -979,10 +1024,15 @@ onBeforeUnmount(() => {
<span
class="messages-avatar messages-avatar--header"
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
<img
v-if="activeContact?.avatar_url"
class="messages-avatar__image"
:src="activeContact.avatar_url"
alt=""
/>
<span v-else-if="activeContact" class="messages-avatar__initials">{{
contactInitials(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
@@ -1026,10 +1076,15 @@ onBeforeUnmount(() => {
<span
class="messages-avatar messages-avatar--contact"
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
<img
v-if="activeContact?.avatar_url"
class="messages-avatar__image"
:src="activeContact.avatar_url"
alt=""
/>
<span v-else-if="activeContact" class="messages-avatar__initials">{{
contactInitials(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
+43 -2
View File
@@ -36,6 +36,7 @@ import {
Play,
Plus,
Search,
Share2,
SkipBack,
SkipForward,
Trash2,
@@ -47,6 +48,7 @@ import type { CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMusicStore } from '@/stores/music'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
@@ -65,6 +67,7 @@ const MUSIC_POPOVER_GAP = 7
const MUSIC_SHEET_TRANSITION_MS = 420
const music = useMusicStore()
const easyShare = useEasyShareStore()
const phone = usePhoneStore()
const activeTab = ref<MusicTab>('library')
const activePlaylist = ref<MusicPlaylist | null>(null)
@@ -189,7 +192,7 @@ function positionPopover(target: HTMLElement, itemCount: number): boolean {
function openAddMenu(event: MouseEvent): void {
const target = event.currentTarget as HTMLElement
if (!positionPopover(target, activePlaylist.value ? 3 : 2)) return
if (!positionPopover(target, activePlaylist.value ? 4 : 2)) return
actionTrack.value = null
actionMenuOpened.value = false
addMenuOpened.value = true
@@ -264,12 +267,44 @@ function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
event.stopPropagation()
actionTrack.value = track
const itemCount =
1 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
2 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
if (!positionPopover(event.currentTarget as HTMLElement, itemCount)) return
addMenuOpened.value = false
actionMenuOpened.value = true
}
function shareTrack(): void {
const track = actionTrack.value
if (!track) return
closeMenus()
easyShare.open({
appId: 'music',
copyText: `${track.title}${track.artist}`,
id: track.id,
imageUrl: track.artwork,
kind: 'track',
link: `skyphone://music/${track.source}/${track.id}`,
meta: { source: track.source },
subtitle: track.artist,
title: track.title,
})
}
function shareActivePlaylist(): void {
const playlist = activePlaylist.value
if (!playlist) return
closeMenus()
easyShare.open({
appId: 'music',
copyText: `${playlist.name} · ${playlist.entries.length}`,
id: playlist.id,
kind: 'playlist',
link: `skyphone://music/playlist/${playlist.id}`,
subtitle: phone.t('Apps.music.songCount', { count: String(playlist.entries.length) }),
title: playlist.name,
})
}
function openPlaylist(playlist: MusicPlaylist): void {
activePlaylist.value = playlist
activeTab.value = 'playlists'
@@ -934,6 +969,9 @@ onBeforeUnmount(() => {
>
<k-list nested>
<template v-if="activePlaylist">
<k-list-button link-component="button" @click="shareActivePlaylist">
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
</k-list-button>
<k-list-button
link-component="button"
@click="openActivePlaylistTrackPicker"
@@ -975,6 +1013,9 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.music.songActions')"
>
<k-list nested>
<k-list-button link-component="button" @click="shareTrack">
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
</k-list-button>
<k-list-button
link-component="button"
@click="openPlaylistPicker(actionTrack)"
+21 -1
View File
@@ -13,7 +13,7 @@ import {
kPopover,
kSearchbar,
} from 'konsta/vue'
import { Ellipsis, Pin, PinOff, SquarePen, Trash2 } from 'lucide-vue-next'
import { Ellipsis, Pin, PinOff, Share2, SquarePen, Trash2 } from 'lucide-vue-next'
import {
computed,
type ComponentPublicInstance,
@@ -23,11 +23,13 @@ import {
} from 'vue'
import { useNotesStore } from '@/stores/notes'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { Note } from '@/utils/notes'
const phone = usePhoneStore()
const notes = useNotesStore()
const easyShare = useEasyShareStore()
const searchQuery = ref('')
const editorId = ref<string | null>(null)
const editorOpened = ref(false)
@@ -179,6 +181,20 @@ function togglePinned(): void {
menuOpened.value = false
}
function shareNote(): void {
const note = persistDraft()
if (!note) return
menuOpened.value = false
easyShare.open({
appId: 'notes',
copyText: note.body || note.title,
id: note.id,
kind: 'note',
subtitle: notePreview(note),
title: noteTitle(note),
})
}
</script>
<template>
@@ -304,6 +320,10 @@ function togglePinned(): void {
@backdropclick="menuOpened = false"
>
<k-list nested>
<k-list-button link-component="button" :colors="pinActionColors" @click="shareNote">
<Share2 :size="18" />
{{ phone.t('Apps.easyShare.name') }}
</k-list-button>
<k-list-button
link-component="button"
:colors="pinActionColors"
+39 -1
View File
@@ -45,6 +45,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCallsStore } from '@/stores/calls'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
@@ -67,6 +68,7 @@ type ContactPhotoContext = {
const phone = usePhoneStore()
const calls = useCallsStore()
const easyShare = useEasyShareStore()
const mediaPicker = useMessageMediaStore()
const messages = useMessagesStore()
const router = useRouter()
@@ -489,6 +491,33 @@ function openCallContact(): void {
openContact(activeCallContact.value ?? undefined, call.otherNumber)
}
function shareSelectedContact(): void {
const contact = selectedContact.value
if (!contact) return
easyShare.open({
appId: 'phone',
copyText: `${contact.name}\n${contact.phone_number}`,
id: contact.id,
imageUrl: contact.avatar_url,
kind: 'contact',
subtitle: formatPhoneNumber(contact.phone_number),
title: contact.name,
})
}
function shareOwnProfile(): void {
const number = phone.device?.sim?.number
if (!number) return
easyShare.open({
appId: 'phone',
copyText: `${phone.t('Apps.phone.myCard')}\n${number}`,
kind: 'profile',
link: `skyphone://phone/${number}`,
subtitle: formatPhoneNumber(number),
title: phone.t('Apps.phone.myCard'),
})
}
function messageActiveCaller(): void {
const number = calls.activeCall?.otherNumber
callMoreOpened.value = false
@@ -954,6 +983,11 @@ onBeforeUnmount(() => {
</p>
</div>
</k-glass>
<k-glass class="phone-profile-card phone-profile-single-option">
<button type="button" @click="shareOwnProfile">
<span>{{ phone.t('Apps.easyShare.shareProfile') }}</span>
</button>
</k-glass>
</section>
<section v-else class="phone-profile-content">
@@ -984,7 +1018,11 @@ onBeforeUnmount(() => {
<button type="button" @click="openMessage(selectedNumber)">
<span>{{ phone.t('Apps.phone.sendMessage') }}</span>
</button>
<button type="button" disabled>
<button
type="button"
:disabled="!selectedContact"
@click="shareSelectedContact"
>
<span>{{ phone.t('Apps.phone.shareContact') }}</span>
</button>
<button
+39
View File
@@ -17,6 +17,7 @@ import {
Plus,
Search,
Send,
Share2,
ShieldAlert,
Trash2,
UserRound,
@@ -58,6 +59,7 @@ import { useRouter } from 'vue-router'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import { usePicstagramStore } from '@/stores/picstagram'
import type { PhoneMedia } from '@/types/media'
@@ -453,6 +455,35 @@ function showPostActions(post: PicstagramPost): void {
actionsOpen.value = true
}
function sharePost(post: PicstagramPost): void {
useEasyShareStore().open({
appId: 'picstagram',
copyText: `@${post.handle}: ${post.caption}`,
id: post.id,
imageUrl: post.media[0]?.url,
kind: 'post',
link: `skyphone://picstagram/post/${post.id}`,
subtitle: `@${post.handle}`,
title: post.caption || post.display_name,
})
}
function shareCurrentProfile(): void {
const profile = currentProfile.value
if (!profile) return
actionsOpen.value = false
useEasyShareStore().open({
appId: 'picstagram',
copyText: `@${profile.handle}`,
id: profile.id,
imageUrl: profile.avatar_url,
kind: 'profile',
link: `skyphone://picstagram/profile/${profile.id}`,
subtitle: `@${profile.handle}`,
title: profile.display_name,
})
}
function startReport(
type: PicstagramReportTarget,
id: string,
@@ -871,6 +902,9 @@ onBeforeUnmount(() => {
@click="openComments(selectedPost)"
><MessageCircle
/></k-link>
<k-link component="button" icon-only :aria-label="phone.t('Apps.easyShare.name')" @click="sharePost(selectedPost)">
<Share2 />
</k-link>
</span>
<k-link
component="button"
@@ -1022,6 +1056,9 @@ onBeforeUnmount(() => {
@click="openComments(post)"
><MessageCircle
/></k-link>
<k-link component="button" icon-only :aria-label="phone.t('Apps.easyShare.name')" @click="sharePost(post)">
<Share2 />
</k-link>
</span>
<k-link
component="button"
@@ -1811,6 +1848,7 @@ onBeforeUnmount(() => {
</template>
</k-actions-group>
<k-actions-group v-else-if="currentProfile && !currentProfile.is_owner">
<k-actions-button @click="shareCurrentProfile">{{ phone.t('Apps.easyShare.name') }}</k-actions-button>
<k-actions-button @click="reportCurrentProfile">{{
t('report')
}}</k-actions-button>
@@ -1819,6 +1857,7 @@ onBeforeUnmount(() => {
}}</k-actions-button>
</k-actions-group>
<k-actions-group v-else-if="currentProfile?.is_owner">
<k-actions-button @click="shareCurrentProfile">{{ phone.t('Apps.easyShare.name') }}</k-actions-button>
<k-actions-button @click="editProfileFromActions">{{
t('editProfile')
}}</k-actions-button>