mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
ADD - implement EasyShare system
This commit is contained in:
+10
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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...',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3076,6 +3076,9 @@ const featherTopics = [
|
||||
]
|
||||
let featherOnboarded = true
|
||||
|
||||
const easyShareHistory = []
|
||||
let easyShareVisibility = 'everyone'
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
@@ -5490,6 +5493,58 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: [...grouped.values()] })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:bootstrap') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
history: easyShareHistory,
|
||||
pending: easyShareHistory.filter((item) =>
|
||||
['pending', 'transferring'].includes(item.status),
|
||||
),
|
||||
targets: [
|
||||
{ distance: 2.4, id: 41, name: 'Mia Santos' },
|
||||
{ distance: 7.8, id: 72, name: 'Noah Walker' },
|
||||
],
|
||||
visibility: easyShareVisibility,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:set-visibility') {
|
||||
easyShareVisibility = request.body.visibility
|
||||
response.json({ success: true, data: { visibility: easyShareVisibility } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:request') {
|
||||
const transfer = {
|
||||
createdAt: Date.now(),
|
||||
direction: 'outgoing',
|
||||
id: `easyshare-${Date.now()}`,
|
||||
otherName: request.body.targetId === 72 ? 'Noah Walker' : 'Mia Santos',
|
||||
payload: request.body.payload,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
}
|
||||
easyShareHistory.unshift(transfer)
|
||||
response.json({ success: true, data: transfer })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:respond' || endpoint === 'easyshare:cancel') {
|
||||
const transfer = easyShareHistory.find((item) => item.id === request.body.id)
|
||||
if (!transfer) {
|
||||
response.json({ success: false, error: 'transfer_not_found' })
|
||||
return
|
||||
}
|
||||
transfer.status =
|
||||
endpoint === 'easyshare:cancel'
|
||||
? 'cancelled'
|
||||
: request.body.accepted
|
||||
? 'completed'
|
||||
: 'declined'
|
||||
transfer.progress = transfer.status === 'completed' ? 100 : transfer.progress
|
||||
response.json({ success: true, data: transfer })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'messages:gifs') {
|
||||
const offset = Math.max(0, Number(request.body.offset ?? 0))
|
||||
const pageSize = 6
|
||||
|
||||
@@ -169,6 +169,17 @@ Config.Messages = {
|
||||
DeleteBatchSize = 20,
|
||||
}
|
||||
|
||||
Config.EasyShare = {
|
||||
Enabled = true,
|
||||
DefaultVisibility = "everyone", -- everyone, contacts or hidden
|
||||
MaximumDistance = 15.0,
|
||||
HistoryLimit = 50,
|
||||
PendingSeconds = 30,
|
||||
TransferDurationMs = 3000,
|
||||
RequestsPerMinute = 12,
|
||||
PayloadMaxBytes = 24000,
|
||||
}
|
||||
|
||||
Config.DarkChat = {
|
||||
AliasMaxLength = 32,
|
||||
BodyMaxLength = 2000,
|
||||
|
||||
@@ -1015,6 +1015,29 @@ Locales["en"] = {
|
||||
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
|
||||
deleteNote = "Delete note",
|
||||
},
|
||||
easyShare = {
|
||||
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
|
||||
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
|
||||
shareProfile = "Share Profile", chooseConversation = "Choose a conversation",
|
||||
savedToNotes = "Saved to Notes.", copied = "Copied.", 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.",
|
||||
copy_failed = "Could not copy this item.", request_failed = "EasyShare is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
photos = {
|
||||
name = "Gallery", count = "{count} items", loading = "Loading Gallery...",
|
||||
emptyTitle = "No Photos or Videos", emptyBody = "Captures from Camera will appear here.",
|
||||
|
||||
@@ -59,6 +59,7 @@ server_scripts {
|
||||
'source/server/calls.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/messages.lua',
|
||||
'source/server/easyshare.lua',
|
||||
'source/server/darkchat.lua',
|
||||
'source/server/flare.lua',
|
||||
'source/server/notes.lua',
|
||||
|
||||
@@ -192,6 +192,11 @@ local server_callbacks = {
|
||||
"messages:media",
|
||||
"messages:delete",
|
||||
"messages:gifs",
|
||||
"easyshare:bootstrap",
|
||||
"easyshare:set-visibility",
|
||||
"easyshare:request",
|
||||
"easyshare:respond",
|
||||
"easyshare:cancel",
|
||||
"darkchat:bootstrap",
|
||||
"darkchat:update-profile",
|
||||
"darkchat:start",
|
||||
@@ -508,6 +513,14 @@ RegisterNetEvent("sky_phone:mail:changed", function(data)
|
||||
SendNUIMessage({ type = "mail:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:easyshare:changed", function(data)
|
||||
SendNUIMessage({ type = "easyshare:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:gallery:changed", function()
|
||||
SendNUIMessage({ type = "gallery:changed" })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:mail:new", function(data)
|
||||
local mail_locale = get_locale().Nui.Apps.mail
|
||||
data.title = mail_locale.name
|
||||
|
||||
@@ -2109,6 +2109,51 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_easyshare_preferences",
|
||||
columns = {
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "visibility", type = "ENUM('everyone','contacts','hidden') NOT NULL DEFAULT 'everyone'" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "device_imei",
|
||||
foreignKeys = {
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_easyshare_transfers",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_name", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "recipient_name", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "content_type", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "payload", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "status", type = "ENUM('pending','transferring','completed','declined','cancelled','expired','failed') NOT NULL DEFAULT 'pending'" },
|
||||
{ name = "progress", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
{ name = "completed_at", type = "DATETIME NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_easyshare_sender", columns = "(`sender_imei`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_easyshare_recipient", columns = "(`recipient_imei`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "sender_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
{ column = "recipient_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local active_transfers = {}
|
||||
local valid_kinds = {
|
||||
contact = true,
|
||||
document = true,
|
||||
link = true,
|
||||
location = true,
|
||||
note = true,
|
||||
photo = true,
|
||||
playlist = true,
|
||||
post = true,
|
||||
profile = true,
|
||||
text = true,
|
||||
track = true,
|
||||
video = true,
|
||||
}
|
||||
local valid_apps = {
|
||||
camera = true,
|
||||
crewlink = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
map = true,
|
||||
messages = true,
|
||||
music = true,
|
||||
notes = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
}
|
||||
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
|
||||
|
||||
local function uuid()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = rows[1] and rows[1].id
|
||||
if type(id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate an EasyShare id.")
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
local function trim(value, maximum)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local result = value:match("^%s*(.-)%s*$")
|
||||
if result == "" or #result > maximum then
|
||||
return nil
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function current_device(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
local device = SkyPhone.LoadDevice(session.imei)
|
||||
if not device then
|
||||
return nil, { success = false, error = "device_not_found" }
|
||||
end
|
||||
return device
|
||||
end
|
||||
|
||||
local function owner_condition(device, alias)
|
||||
local prefix = alias and ("`" .. alias .. "`.") or ""
|
||||
if device.account_id then
|
||||
return prefix .. "`account_id` = ?", { tonumber(device.account_id) }
|
||||
end
|
||||
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { device.imei }
|
||||
end
|
||||
|
||||
local function append_params(target, values)
|
||||
for _, value in ipairs(values) do
|
||||
target[#target + 1] = value
|
||||
end
|
||||
end
|
||||
|
||||
local function display_name(source)
|
||||
local first = trim(Bridge.Framework.GetFirstname(source), 80)
|
||||
local last = trim(Bridge.Framework.GetLastname(source), 80)
|
||||
local name = table.concat({ first or "", last or "" }, " "):match("^%s*(.-)%s*$")
|
||||
return name ~= "" and name or GetPlayerName(source) or ("Player %s"):format(source)
|
||||
end
|
||||
|
||||
local function visibility_for(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `visibility` FROM `sky_phone_easyshare_preferences`
|
||||
WHERE `device_imei` = ? LIMIT 1
|
||||
]], { imei })
|
||||
local visibility = rows[1] and rows[1].visibility or Config.EasyShare.DefaultVisibility
|
||||
return valid_visibilities[visibility] and visibility or "hidden"
|
||||
end
|
||||
|
||||
local function distance_between(left_source, right_source)
|
||||
if GetPlayerRoutingBucket(left_source) ~= GetPlayerRoutingBucket(right_source) then
|
||||
return nil
|
||||
end
|
||||
local left_ped = GetPlayerPed(left_source)
|
||||
local right_ped = GetPlayerPed(right_source)
|
||||
if left_ped == 0 or right_ped == 0 then
|
||||
return nil
|
||||
end
|
||||
return #(GetEntityCoords(left_ped) - GetEntityCoords(right_ped))
|
||||
end
|
||||
|
||||
local function visible_to(target_device, sender_device, visibility)
|
||||
if visibility == "everyone" then
|
||||
return true
|
||||
end
|
||||
if visibility ~= "contacts" or not sender_device.phone_number then
|
||||
return false
|
||||
end
|
||||
local condition, owner_params = owner_condition(target_device)
|
||||
local params = { sender_device.phone_number }
|
||||
append_params(params, owner_params)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id` FROM `sky_phone_contacts`
|
||||
WHERE `phone_number` = ? AND %s LIMIT 1
|
||||
]]):format(condition), params)
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function target_state(sender_source, target_source, sender_device)
|
||||
if target_source == sender_source then
|
||||
return nil
|
||||
end
|
||||
local target_device = current_device(target_source)
|
||||
if not target_device then
|
||||
return nil
|
||||
end
|
||||
local visibility = visibility_for(target_device.imei)
|
||||
if not visible_to(target_device, sender_device, visibility) then
|
||||
return nil
|
||||
end
|
||||
local distance = distance_between(sender_source, target_source)
|
||||
if not distance or distance > Config.EasyShare.MaximumDistance then
|
||||
return nil
|
||||
end
|
||||
return { device = target_device, distance = distance }
|
||||
end
|
||||
|
||||
local function nearby_targets(source, device)
|
||||
local targets = {}
|
||||
for _, target in ipairs(Bridge.Framework.GetPlayers()) do
|
||||
local target_source = tonumber(target)
|
||||
if target_source then
|
||||
local state = target_state(source, target_source, device)
|
||||
if state then
|
||||
targets[#targets + 1] = {
|
||||
id = target_source,
|
||||
name = display_name(target_source),
|
||||
distance = math.floor(state.distance * 10 + 0.5) / 10,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(targets, function(left, right)
|
||||
return left.distance < right.distance
|
||||
end)
|
||||
return targets
|
||||
end
|
||||
|
||||
local function sanitize_payload(source, device, data)
|
||||
if type(data) ~= "table" or not valid_kinds[data.kind] then
|
||||
return nil, "invalid_payload"
|
||||
end
|
||||
local title = trim(data.title, 160)
|
||||
local copy_text = trim(data.copyText, 4000)
|
||||
local app_id = trim(data.appId, 48)
|
||||
if not title or not copy_text or not app_id or not valid_apps[app_id] then
|
||||
return nil, "invalid_payload"
|
||||
end
|
||||
local payload = {
|
||||
appId = app_id,
|
||||
kind = data.kind,
|
||||
title = title,
|
||||
copyText = copy_text,
|
||||
}
|
||||
if type(data.id) == "string" and #data.id <= 128 then
|
||||
payload.id = data.id
|
||||
elseif type(data.id) == "number" and data.id > 0 and data.id == math.floor(data.id) then
|
||||
payload.id = data.id
|
||||
end
|
||||
if type(data.subtitle) == "string" and #data.subtitle <= 240 then
|
||||
payload.subtitle = data.subtitle
|
||||
end
|
||||
if type(data.link) == "string" and #data.link <= 500 and data.link:match("^[%w_%-]+://") then
|
||||
payload.link = data.link
|
||||
end
|
||||
|
||||
local condition, owner_params = owner_condition(device)
|
||||
if data.kind == "contact" and type(payload.id) == "string" then
|
||||
local params = { payload.id }
|
||||
append_params(params, owner_params)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `name`, `phone_number`, `organization`, `notes`
|
||||
FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1
|
||||
]]):format(condition), params)
|
||||
local contact = rows[1]
|
||||
if not contact then
|
||||
return nil, "not_owned"
|
||||
end
|
||||
payload.title = contact.name
|
||||
payload.subtitle = contact.phone_number
|
||||
payload.copyText = ("%s\n%s"):format(contact.name, contact.phone_number)
|
||||
payload.meta = {
|
||||
name = contact.name,
|
||||
notes = contact.notes,
|
||||
organization = contact.organization,
|
||||
phoneNumber = contact.phone_number,
|
||||
}
|
||||
elseif data.kind == "note" and type(payload.id) == "string" then
|
||||
local params = { payload.id }
|
||||
append_params(params, owner_params)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `title`, `body` FROM `sky_phone_notes`
|
||||
WHERE `id` = ? AND %s LIMIT 1
|
||||
]]):format(condition), params)
|
||||
local note = rows[1]
|
||||
if not note then
|
||||
return nil, "not_owned"
|
||||
end
|
||||
payload.title = note.title ~= "" and note.title or title
|
||||
payload.copyText = note.body
|
||||
payload.meta = { body = note.body, title = note.title }
|
||||
elseif data.kind == "photo" or data.kind == "video" then
|
||||
local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind)
|
||||
if not url then
|
||||
return nil, media_error or "not_owned"
|
||||
end
|
||||
local media_params = { tonumber(payload.id) }
|
||||
append_params(media_params, owner_params)
|
||||
local media_rows = Bridge.Database.Query(([[
|
||||
SELECT `remote_id` FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s LIMIT 1
|
||||
]]):format(condition), media_params)
|
||||
if not media_rows[1] then
|
||||
return nil, "not_owned"
|
||||
end
|
||||
payload.imageUrl = url
|
||||
payload.meta = { remoteId = media_rows[1].remote_id, url = url }
|
||||
elseif data.kind == "location" then
|
||||
local ped = GetPlayerPed(source)
|
||||
if ped == 0 then
|
||||
return nil, "location_unavailable"
|
||||
end
|
||||
local coords = GetEntityCoords(ped)
|
||||
payload.meta = { x = coords.x, y = coords.y, z = coords.z }
|
||||
elseif type(data.imageUrl) == "string" and #data.imageUrl <= 2048 and data.imageUrl:match("^https://") then
|
||||
payload.imageUrl = data.imageUrl
|
||||
end
|
||||
|
||||
local encoded = json.encode(payload)
|
||||
if #encoded > Config.EasyShare.PayloadMaxBytes then
|
||||
return nil, "payload_too_large"
|
||||
end
|
||||
return payload, nil, encoded
|
||||
end
|
||||
|
||||
local function transfer_for_source(transfer, source)
|
||||
local incoming = transfer.recipient_source == source
|
||||
return {
|
||||
id = transfer.id,
|
||||
direction = incoming and "incoming" or "outgoing",
|
||||
otherName = incoming and transfer.sender_name or transfer.recipient_name,
|
||||
payload = transfer.payload,
|
||||
progress = transfer.progress,
|
||||
status = transfer.status,
|
||||
createdAt = transfer.created_at,
|
||||
}
|
||||
end
|
||||
|
||||
local function notify_transfer(transfer)
|
||||
TriggerClientEvent("sky_phone:easyshare:changed", transfer.sender_source, {
|
||||
transfer = transfer_for_source(transfer, transfer.sender_source),
|
||||
})
|
||||
TriggerClientEvent("sky_phone:easyshare:changed", transfer.recipient_source, {
|
||||
transfer = transfer_for_source(transfer, transfer.recipient_source),
|
||||
})
|
||||
end
|
||||
|
||||
local function persist_state(transfer)
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_easyshare_transfers`
|
||||
SET `status` = ?, `progress` = ?, `updated_at` = CURRENT_TIMESTAMP,
|
||||
`completed_at` = IF(? IN ('completed','declined','cancelled','expired','failed'), CURRENT_TIMESTAMP, `completed_at`)
|
||||
WHERE `id` = ?
|
||||
]], { transfer.status, transfer.progress, transfer.status, transfer.id })
|
||||
end
|
||||
|
||||
local function finish_transfer(transfer, status)
|
||||
if transfer.status ~= "pending" and transfer.status ~= "transferring" then
|
||||
return
|
||||
end
|
||||
transfer.status = status
|
||||
transfer.progress = status == "completed" and 100 or transfer.progress
|
||||
persist_state(transfer)
|
||||
notify_transfer(transfer)
|
||||
active_transfers[transfer.id] = nil
|
||||
end
|
||||
|
||||
local function apply_received_payload(transfer)
|
||||
local device = SkyPhone.LoadDevice(transfer.recipient_imei)
|
||||
if not device then
|
||||
return false
|
||||
end
|
||||
local account_id = device.account_id and tonumber(device.account_id) or nil
|
||||
local meta = transfer.payload.meta or {}
|
||||
if transfer.payload.kind == "contact" then
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_contacts`
|
||||
(`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?)
|
||||
]], {
|
||||
uuid(), uuid(), account_id, account_id and nil or device.imei,
|
||||
meta.name, meta.notes or "", meta.organization or "", meta.phoneNumber,
|
||||
})
|
||||
TriggerClientEvent("sky_phone:contacts:changed", transfer.recipient_source, {})
|
||||
elseif transfer.payload.kind == "note" then
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_notes`
|
||||
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
]], { uuid(), account_id, account_id and nil or device.imei, meta.title or "", meta.body or "" })
|
||||
if account_id then
|
||||
SkyPhone.RefreshAccount(account_id)
|
||||
else
|
||||
SkyPhone.RefreshDevice(device.imei)
|
||||
end
|
||||
elseif transfer.payload.kind == "location" then
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_map_markers`
|
||||
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
|
||||
VALUES (?, ?, ?, 'blue', ?, ?, ?)
|
||||
]], { uuid(), device.imei, transfer.payload.title:sub(1, 40), meta.x, meta.y, meta.z })
|
||||
elseif transfer.payload.kind == "photo" or transfer.payload.kind == "video" then
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
]], {
|
||||
account_id, account_id and nil or device.imei, meta.url, meta.remoteId, transfer.payload.kind,
|
||||
})
|
||||
TriggerClientEvent("sky_phone:gallery:changed", transfer.recipient_source, {})
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function advance_transfer(id)
|
||||
local transfer = active_transfers[id]
|
||||
if not transfer or transfer.status ~= "transferring" then
|
||||
return
|
||||
end
|
||||
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
|
||||
if not distance or distance > Config.EasyShare.MaximumDistance then
|
||||
finish_transfer(transfer, "failed")
|
||||
return
|
||||
end
|
||||
transfer.progress = math.min(100, transfer.progress + 10)
|
||||
persist_state(transfer)
|
||||
notify_transfer(transfer)
|
||||
if transfer.progress >= 100 then
|
||||
if apply_received_payload(transfer) then
|
||||
finish_transfer(transfer, "completed")
|
||||
else
|
||||
finish_transfer(transfer, "failed")
|
||||
end
|
||||
return
|
||||
end
|
||||
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
|
||||
advance_transfer(id)
|
||||
end)
|
||||
end
|
||||
|
||||
local function row_transfer(row, current_imei)
|
||||
local decoded = json.decode(row.payload)
|
||||
return {
|
||||
id = row.id,
|
||||
direction = row.recipient_imei == current_imei and "incoming" or "outgoing",
|
||||
otherName = row.recipient_imei == current_imei and row.sender_name or row.recipient_name,
|
||||
payload = decoded,
|
||||
progress = tonumber(row.progress) or 0,
|
||||
status = row.status,
|
||||
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
|
||||
}
|
||||
end
|
||||
|
||||
local function history(device)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`,
|
||||
`payload`, `status`, `progress`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
||||
FROM `sky_phone_easyshare_transfers`
|
||||
WHERE `sender_imei` = ? OR `recipient_imei` = ?
|
||||
ORDER BY `created_at` DESC LIMIT ?
|
||||
]], { device.imei, device.imei, Config.EasyShare.HistoryLimit })
|
||||
local result = {}
|
||||
for index, row in ipairs(rows) do
|
||||
result[index] = row_transfer(row, device.imei)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
|
||||
if not Config.EasyShare.Enabled then
|
||||
return { success = false, error = "disabled" }
|
||||
end
|
||||
local device, error_response = current_device(source)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local pending = {}
|
||||
for _, transfer in pairs(active_transfers) do
|
||||
if transfer.sender_source == source or transfer.recipient_source == source then
|
||||
pending[#pending + 1] = transfer_for_source(transfer, source)
|
||||
end
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
history = history(device),
|
||||
pending = pending,
|
||||
targets = nearby_targets(source, device),
|
||||
visibility = visibility_for(device.imei),
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data)
|
||||
local device, error_response = current_device(source)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local visibility = type(data) == "table" and data.visibility or nil
|
||||
if not valid_visibilities[visibility] then
|
||||
return { success = false, error = "invalid_visibility" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_easyshare_preferences` (`device_imei`, `visibility`)
|
||||
VALUES (?, ?) ON DUPLICATE KEY UPDATE `visibility` = VALUES(`visibility`)
|
||||
]], { device.imei, visibility })
|
||||
return { success = true, data = { visibility = visibility } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "easyshare_request", Config.EasyShare.RequestsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local sender_device, error_response = current_device(source)
|
||||
if not sender_device then
|
||||
return error_response
|
||||
end
|
||||
local target_source = type(data) == "table" and tonumber(data.targetId) or nil
|
||||
if not target_source or target_source ~= math.floor(target_source) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
local target = target_state(source, target_source, sender_device)
|
||||
if not target then
|
||||
return { success = false, error = "target_unavailable" }
|
||||
end
|
||||
local payload, payload_error, encoded = sanitize_payload(source, sender_device, data.payload)
|
||||
if not payload then
|
||||
return { success = false, error = payload_error }
|
||||
end
|
||||
local id = uuid()
|
||||
local transfer = {
|
||||
id = id,
|
||||
sender_source = source,
|
||||
recipient_source = target_source,
|
||||
sender_imei = sender_device.imei,
|
||||
recipient_imei = target.device.imei,
|
||||
sender_name = display_name(source),
|
||||
recipient_name = display_name(target_source),
|
||||
payload = payload,
|
||||
progress = 0,
|
||||
status = "pending",
|
||||
created_at = os.time() * 1000,
|
||||
}
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_easyshare_transfers`
|
||||
(`id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`, `content_type`, `payload`, `status`, `progress`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0)
|
||||
]], { id, sender_device.imei, target.device.imei, transfer.sender_name, transfer.recipient_name, payload.kind, encoded })
|
||||
active_transfers[id] = transfer
|
||||
notify_transfer(transfer)
|
||||
SetTimeout(Config.EasyShare.PendingSeconds * 1000, function()
|
||||
local current = active_transfers[id]
|
||||
if current and current.status == "pending" then
|
||||
finish_transfer(current, "expired")
|
||||
end
|
||||
end)
|
||||
return { success = true, data = transfer_for_source(transfer, source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
local transfer = type(id) == "string" and active_transfers[id] or nil
|
||||
if not transfer or transfer.recipient_source ~= source or transfer.status ~= "pending" or type(data.accepted) ~= "boolean" then
|
||||
return { success = false, error = "transfer_not_found" }
|
||||
end
|
||||
if not data.accepted then
|
||||
finish_transfer(transfer, "declined")
|
||||
return { success = true, data = transfer_for_source(transfer, source) }
|
||||
end
|
||||
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
|
||||
if not distance or distance > Config.EasyShare.MaximumDistance then
|
||||
finish_transfer(transfer, "failed")
|
||||
return { success = false, error = "too_far" }
|
||||
end
|
||||
transfer.status = "transferring"
|
||||
persist_state(transfer)
|
||||
notify_transfer(transfer)
|
||||
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
|
||||
advance_transfer(id)
|
||||
end)
|
||||
return { success = true, data = transfer_for_source(transfer, source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:easyshare:cancel", function(source, data)
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
local transfer = type(id) == "string" and active_transfers[id] or nil
|
||||
if not transfer or (transfer.sender_source ~= source and transfer.recipient_source ~= source) then
|
||||
return { success = false, error = "transfer_not_found" }
|
||||
end
|
||||
finish_transfer(transfer, "cancelled")
|
||||
return { success = true, data = transfer_for_source(transfer, source) }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
local dropped_source = source
|
||||
for _, transfer in pairs(active_transfers) do
|
||||
if transfer.sender_source == dropped_source or transfer.recipient_source == dropped_source then
|
||||
finish_transfer(transfer, "failed")
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -580,19 +580,25 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
|
||||
delete_result(src, correlation_id, false, "not_found", media_id)
|
||||
return
|
||||
end
|
||||
if pending_deletes[media_id] then
|
||||
if pending_deletes[row.remote_id] then
|
||||
delete_result(src, correlation_id, false, "operation_in_progress", media_id)
|
||||
return
|
||||
end
|
||||
pending_deletes[media_id] = src
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[media_id] = nil
|
||||
delete_result(src, correlation_id, false, delete_error, media_id)
|
||||
return
|
||||
pending_deletes[row.remote_id] = src
|
||||
local references = Bridge.Database.Query(
|
||||
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
|
||||
{ row.remote_id }
|
||||
)
|
||||
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[row.remote_id] = nil
|
||||
delete_result(src, correlation_id, false, delete_error, media_id)
|
||||
return
|
||||
end
|
||||
end
|
||||
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
|
||||
pending_deletes[media_id] = nil
|
||||
pending_deletes[row.remote_id] = nil
|
||||
delete_result(src, correlation_id, true, nil, media_id)
|
||||
end)
|
||||
|
||||
@@ -606,8 +612,18 @@ end
|
||||
|
||||
function SkyPhoneMedia.CleanupRemoteFiles(rows)
|
||||
CreateThread(function()
|
||||
local checked = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
local references = checked[row.remote_id] and { { count = 1 } } or Bridge.Database.Query(
|
||||
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
|
||||
{ row.remote_id }
|
||||
)
|
||||
checked[row.remote_id] = true
|
||||
local in_use = (tonumber(references[1] and references[1].count) or 0) > 0
|
||||
local deleted, delete_error = true, nil
|
||||
if not in_use then
|
||||
deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
end
|
||||
if not deleted then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
|
||||
Reference in New Issue
Block a user