ENH - complete and secure EasyShare integration

This commit is contained in:
smx.pusha
2026-08-12 07:15:26 +02:00
parent 14f0b7a178
commit eeb3396f52
18 changed files with 959 additions and 39 deletions
+8 -4
View File
@@ -239,14 +239,18 @@ EasyShare browser data is available from every app that exposes a share action.
Gallery directly, share an item, and then use the EasyShare and History actions in the sheet: Gallery directly, share an item, and then use the EasyShare and History actions in the sheet:
- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired, - Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired,
and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/gallery` and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/photos`
- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/gallery` - Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/photos`
- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/gallery` - Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/photos`
- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/gallery` - Complete content catalog with contact, document, link, location, note, photo, playlist, post,
profile, text, track, and video: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-catalog#/apps/photos`
- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/photos`
In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a
transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and
accepting or declining the seeded incoming request are kept in memory until the mock server restarts. accepting or declining the seeded incoming request are kept in memory until the mock server restarts.
The catalog also contains source examples from Companies, Mail, Garage, and House; completed history
rows and rich chat cards can be clicked to verify app/deep-link navigation.
The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging. The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
+45 -28
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { kButton, kGlass, kLink, kPreloader, kSheet } from 'konsta/vue' import { kButton, kGlass, kLink, kList, kListInput, kListItem, kPreloader, kSheet } from 'konsta/vue'
import { import {
Check, Check,
Clock3, Clock3,
@@ -27,6 +27,7 @@ import type {
EasyShareTransfer, EasyShareTransfer,
EasyShareVisibility, EasyShareVisibility,
} from '@/types/easyshare' } from '@/types/easyshare'
import { openEasySharePayload } from '@/utils/easyshare'
const phone = usePhoneStore() const phone = usePhoneStore()
const appStore = useAppStoreStore() const appStore = useAppStoreStore()
@@ -44,7 +45,7 @@ let dragPointerId: number | null = null
let dragStartTime = 0 let dragStartTime = 0
let dragStartY = 0 let dragStartY = 0
const visibilityOptions: EasyShareVisibility[] = ['everyone', 'contacts', 'hidden'] const visibilityOptions: EasyShareVisibility[] = ['everyone', 'contacts', 'hidden']
type EasyShareDestinationAppId = 'darkchat' | 'messages' | 'notes' type EasyShareDestinationAppId = 'darkchat' | 'flare' | 'messages' | 'notes'
const app = computed(() => const app = computed(() =>
easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined, easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined,
) )
@@ -57,7 +58,7 @@ const sharePeople = computed(() => {
}> = [] }> = []
const phoneNumbers = new Set<string>() const phoneNumbers = new Set<string>()
if (easyShare.payload?.appId === 'flare') { if (!appStore.homeLayout.hidden.includes('flare')) {
for (const match of flare.matches) { for (const match of flare.matches) {
people.push({ people.push({
avatar: match.profile.photoUrls[0], avatar: match.profile.photoUrls[0],
@@ -107,6 +108,7 @@ const sharePeople = computed(() => {
const shareAppIds: EasyShareDestinationAppId[] = [ const shareAppIds: EasyShareDestinationAppId[] = [
'messages', 'messages',
'darkchat', 'darkchat',
'flare',
'notes', 'notes',
] ]
const shareApps = computed(() => const shareApps = computed(() =>
@@ -193,14 +195,14 @@ function shareToChat(kind: EasyShareChatApp, targetId: string): void {
void router.push(`/apps/${kind}`) void router.push(`/apps/${kind}`)
} }
function openChatApp(kind: 'darkchat' | 'messages'): void { function openChatApp(kind: EasyShareChatApp): void {
if (!easyShare.prepareChatDraft(kind)) return if (!easyShare.prepareChatDraft(kind)) return
close() close()
void router.push(`/apps/${kind}`) void router.push(`/apps/${kind}`)
} }
function openShareApp(appId: EasyShareDestinationAppId): void { function openShareApp(appId: EasyShareDestinationAppId): void {
if (appId === 'messages' || appId === 'darkchat') { if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId) openChatApp(appId)
return return
} }
@@ -235,6 +237,11 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
feedback.value = label('errors.request_failed') feedback.value = label('errors.request_failed')
} }
} }
async function openTransfer(transfer: EasyShareTransfer): Promise<void> {
close()
await openEasySharePayload(router, transfer.payload)
}
</script> </script>
<template> <template>
@@ -329,18 +336,19 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
<strong>{{ label('nearby') }}</strong> <strong>{{ label('nearby') }}</strong>
<k-link component="button" @click="easyShare.showHistory"><History :size="18" /></k-link> <k-link component="button" @click="easyShare.showHistory"><History :size="18" /></k-link>
</div> </div>
<div class="easyshare-visibility"> <k-list inset strong class="easyshare-visibility">
<ShieldCheck :size="18" /> <k-list-input
<select type="select"
:label="label('visibility')"
:value="easyShare.visibility" :value="easyShare.visibility"
:aria-label="label('visibility')"
@change="easyShare.setVisibility(($event.target as HTMLSelectElement).value as EasyShareVisibility)" @change="easyShare.setVisibility(($event.target as HTMLSelectElement).value as EasyShareVisibility)"
> >
<template #media><ShieldCheck :size="18" /></template>
<option v-for="option in visibilityOptions" :key="option" :value="option"> <option v-for="option in visibilityOptions" :key="option" :value="option">
{{ label(`visibilityOptions.${option}`) }} {{ label(`visibilityOptions.${option}`) }}
</option> </option>
</select> </k-list-input>
</div> </k-list>
<k-glass v-if="easyShare.incomingTransfer" class="easyshare-incoming"> <k-glass v-if="easyShare.incomingTransfer" class="easyshare-incoming">
<UserRound :size="28" /> <UserRound :size="28" />
@@ -369,13 +377,19 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
</k-glass> </k-glass>
<div v-if="easyShare.loading" class="easyshare-loading"><k-preloader /></div> <div v-if="easyShare.loading" class="easyshare-loading"><k-preloader /></div>
<div v-else-if="easyShare.targets.length" class="easyshare-targets"> <k-list v-else-if="easyShare.targets.length" inset strong class="easyshare-targets">
<button v-for="target in easyShare.targets" :key="target.id" type="button" @click="requestTransfer(target.id)"> <k-list-item
<span><UserRound /></span> v-for="target in easyShare.targets"
<div><strong>{{ target.name }}</strong><small>{{ label('distance', { distance: String(target.distance) }) }}</small></div> :key="target.id"
<Share2 :size="18" /> link
</button> :title="target.name"
</div> :subtitle="label('distance', { distance: String(target.distance) })"
@click="requestTransfer(target.id)"
>
<template #media><span><UserRound /></span></template>
<template #after><Share2 :size="18" /></template>
</k-list-item>
</k-list>
<p v-else class="easyshare-empty">{{ label('noNearby') }}</p> <p v-else class="easyshare-empty">{{ label('noNearby') }}</p>
</section> </section>
@@ -385,17 +399,20 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
<strong>{{ label('history') }}</strong> <strong>{{ label('history') }}</strong>
<span></span> <span></span>
</div> </div>
<div class="easyshare-history"> <k-list inset strong class="easyshare-history">
<article v-for="transfer in easyShare.history" :key="transfer.id"> <k-list-item
<span><Clock3 /></span> v-for="transfer in easyShare.history"
<div> :key="transfer.id"
<strong>{{ transfer.payload.title }}</strong> link
<small>{{ transfer.otherName }} · {{ statusLabel(transfer) }}</small> :title="transfer.payload.title"
</div> :subtitle="`${transfer.otherName} · ${statusLabel(transfer)}`"
<small>{{ transfer.direction === 'incoming' ? '↓' : '↑' }}</small> @click="openTransfer(transfer)"
</article> >
<template #media><span><Clock3 /></span></template>
<template #after>{{ transfer.direction === 'incoming' ? '↓' : '↑' }}</template>
</k-list-item>
<p v-if="!easyShare.history.length" class="easyshare-empty">{{ label('noHistory') }}</p> <p v-if="!easyShare.history.length" class="easyshare-empty">{{ label('noHistory') }}</p>
</div> </k-list>
</section> </section>
<p v-if="feedback" class="easyshare-feedback">{{ feedback }}</p> <p v-if="feedback" class="easyshare-feedback">{{ feedback }}</p>
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next' import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps' import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { EasySharePayload } from '@/types/easyshare' import type { EasySharePayload } from '@/types/easyshare'
import { openEasySharePayload } from '@/utils/easyshare'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -15,6 +17,7 @@ const props = withDefaults(
{ compact: false, variant: 'messages' }, { compact: false, variant: 'messages' },
) )
const phone = usePhoneStore() const phone = usePhoneStore()
const router = useRouter()
const imageFailed = ref(false) const imageFailed = ref(false)
const sourceApp = computed(() => getPhoneApp(props.payload.appId)) const sourceApp = computed(() => getPhoneApp(props.payload.appId))
const isVideo = computed( const isVideo = computed(
@@ -47,6 +50,11 @@ watch(
`shared-content-card--${variant}`, `shared-content-card--${variant}`,
{ 'shared-content-card--compact': compact }, { 'shared-content-card--compact': compact },
]" ]"
role="button"
tabindex="0"
@click="openEasySharePayload(router, payload)"
@keydown.enter.prevent="openEasySharePayload(router, payload)"
@keydown.space.prevent="openEasySharePayload(router, payload)"
> >
<div class="shared-content-card__source"> <div class="shared-content-card__source">
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" /> <img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
@@ -92,6 +100,12 @@ watch(
color: #171719; color: #171719;
background: rgb(255 255 255 / 96%); background: rgb(255 255 255 / 96%);
box-shadow: 0 8px 24px rgb(19 45 78 / 13%); box-shadow: 0 8px 24px rgb(19 45 78 / 13%);
cursor: pointer;
}
.shared-content-card:focus-visible {
outline: 3px solid #0a84ff;
outline-offset: 2px;
} }
.shared-content-card__source { .shared-content-card__source {
+2
View File
@@ -353,6 +353,7 @@ const defaultLocales: LocaleTree = {
destinations: 'Share destinations', destinations: 'Share destinations',
newMessage: 'New Message', newMessage: 'New Message',
shareProfile: 'Share Profile', shareProfile: 'Share Profile',
share: 'Share',
kinds: { kinds: {
contact: 'Contact', contact: 'Contact',
document: 'Document', document: 'Document',
@@ -404,6 +405,7 @@ const defaultLocales: LocaleTree = {
transfer_not_found: 'That transfer is no longer available.', transfer_not_found: 'That transfer is no longer available.',
too_far: 'The recipient is too far away.', too_far: 'The recipient is too far away.',
not_owned: 'This item no longer belongs to this phone.', not_owned: 'This item no longer belongs to this phone.',
unsupported_payload: 'This item is not available for EasyShare.',
payload_too_large: 'This item is too large to share.', payload_too_large: 'This item is too large to share.',
location_unavailable: 'Your location is unavailable.', location_unavailable: 'Your location is unavailable.',
rate_limited: 'Too many share requests. Try again shortly.', rate_limited: 'Too many share requests. Try again shortly.',
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { EasySharePayload } from '@/types/easyshare'
import { easyShareRoute } from '@/utils/easyshare'
function payload(overrides: Partial<EasySharePayload>): EasySharePayload {
return {
appId: 'notes',
copyText: 'Shared content',
kind: 'note',
title: 'Shared content',
...overrides,
}
}
describe('EasyShare deep links', () => {
it.each([
['skyphone://media/42', '/apps/photos'],
['skyphone://location/current', '/apps/map'],
['skyphone://phone/5550142', '/apps/phone'],
['skyphone://music/server/night-drive', '/apps/music'],
['skyphone://citymarkt/listing/listing-1', '/apps/citymarkt'],
])('routes %s into its source app', (link, path) => {
expect(easyShareRoute(payload({ link })).path).toBe(path)
})
it('falls back to the payload app and preserves share context', () => {
expect(
easyShareRoute(payload({ appId: 'calendar', id: 'event-1' })),
).toEqual({
path: '/apps/calendar',
query: {
easyShareId: 'event-1',
easyShareKind: 'note',
easyShareLink: '',
},
})
})
it('opens CityMarkt links on the referenced listing', () => {
expect(
easyShareRoute(
payload({
appId: 'citymarkt',
id: 'listing-1',
kind: 'link',
link: 'skyphone://citymarkt/listing/listing-1',
}),
).query.listingId,
).toBe('listing-1')
})
})
+40
View File
@@ -0,0 +1,40 @@
import type { Router } from 'vue-router'
import { getPhoneApp } from '@/config/apps'
import type { EasySharePayload } from '@/types/easyshare'
const schemeRoutes: Record<string, string> = {
location: '/apps/map',
media: '/apps/photos',
phone: '/apps/phone',
}
export function easyShareRoute(payload: EasySharePayload): {
path: string
query: Record<string, string>
} {
const match = payload.link?.match(/^skyphone:\/\/([^/]+)(?:\/([^/]+))?(?:\/(.+))?$/)
const schemeApp = match?.[1]
const app = schemeApp ? getPhoneApp(schemeApp) : getPhoneApp(payload.appId)
const path =
(schemeApp && schemeRoutes[schemeApp]) || app?.route || '/'
const query: Record<string, string> = {
easyShareId: String(payload.id ?? match?.[3] ?? match?.[2] ?? ''),
easyShareKind: payload.kind,
easyShareLink: payload.link ?? '',
}
if (schemeApp === 'citymarkt' && match?.[2] === 'listing' && match[3]) {
query.listingId = decodeURIComponent(match[3])
}
return {
path,
query,
}
}
export async function openEasySharePayload(
router: Router,
payload: EasySharePayload,
): Promise<void> {
await router.push(easyShareRoute(payload))
}
+24
View File
@@ -13,6 +13,7 @@ import {
Plus, Plus,
Rows3, Rows3,
Search, Search,
Share2,
Trash2, Trash2,
UserRound, UserRound,
X, X,
@@ -22,6 +23,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useAccountStore } from '@/stores/account' import { useAccountStore } from '@/stores/account'
import { useCalendarStore } from '@/stores/calendar' import { useCalendarStore } from '@/stores/calendar'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar' import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
@@ -42,6 +44,7 @@ type YearOverview = {
const phone = usePhoneStore() const phone = usePhoneStore()
const account = useAccountStore() const account = useAccountStore()
const calendar = useCalendarStore() const calendar = useCalendarStore()
const easyShare = useEasyShareStore()
const today = new Date() const today = new Date()
const visibleYear = ref(today.getFullYear()) const visibleYear = ref(today.getFullYear())
const selectedDate = ref(dateKey(today)) const selectedDate = ref(dateKey(today))
@@ -358,6 +361,19 @@ function openEdit(): void {
screen.value = 'form' screen.value = 'form'
} }
function shareEvent(): void {
if (!selectedEvent.value) return
easyShare.open({
appId: 'calendar',
copyText: `${selectedEvent.value.title}\n${selectedEvent.value.note}`,
id: selectedEvent.value.id,
kind: 'document',
link: `skyphone://calendar/event/${selectedEvent.value.id}`,
subtitle: formatLongDate(selectedEvent.value.startsAt),
title: selectedEvent.value.title,
})
}
function closeForm(): void { function closeForm(): void {
timePickerField.value = null timePickerField.value = null
screen.value = editingEvent.value ? 'detail' : 'main' screen.value = editingEvent.value ? 'detail' : 'main'
@@ -781,6 +797,10 @@ onMounted(async () => {
<p>{{ selectedEvent.note }}</p> <p>{{ selectedEvent.note }}</p>
</div> </div>
</section> </section>
<button class="calendar__delete calendar__share" type="button" @click="shareEvent">
<Share2 :size="18" />
{{ phone.t('Apps.easyShare.share') }}
</button>
<button class="calendar__delete" type="button" @click="deleteEvent"> <button class="calendar__delete" type="button" @click="deleteEvent">
<Trash2 :size="18" /> <Trash2 :size="18" />
{{ phone.t('Apps.calendar.deleteEvent') }} {{ phone.t('Apps.calendar.deleteEvent') }}
@@ -1739,6 +1759,10 @@ onMounted(async () => {
font-size: 14px !important; font-size: 14px !important;
} }
.calendar__share {
color: #0a84ff !important;
}
.calendar__group--fields input, .calendar__group--fields input,
.calendar__group--fields textarea { .calendar__group--fields textarea {
width: 100%; width: 100%;
+23
View File
@@ -50,6 +50,7 @@ import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
import { useAccountStore } from '@/stores/account' import { useAccountStore } from '@/stores/account'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMarketplaceStore } from '@/stores/marketplace' import { useMarketplaceStore } from '@/stores/marketplace'
import { useMessageMediaStore } from '@/stores/messageMedia' import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages' import { usePagesStore } from '@/stores/pages'
@@ -115,6 +116,7 @@ const phone = usePhoneStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const account = useAccountStore() const account = useAccountStore()
const easyShare = useEasyShareStore()
const marketplace = useMarketplaceStore() const marketplace = useMarketplaceStore()
const messageMedia = useMessageMediaStore() const messageMedia = useMessageMediaStore()
const pages = usePagesStore() const pages = usePagesStore()
@@ -367,6 +369,19 @@ async function shareToLocalPages(): Promise<void> {
) )
} }
function shareListing(): void {
if (!selectedListing.value) return
easyShare.open({
appId: 'citymarkt',
copyText: `${selectedListing.value.title}\n${selectedListing.value.description}`,
id: selectedListing.value.id,
kind: 'link',
link: `skyphone://citymarkt/listing/${selectedListing.value.id}`,
subtitle: formatPrice(selectedListing.value),
title: selectedListing.value.title,
})
}
async function loadFeed(): Promise<void> { async function loadFeed(): Promise<void> {
await marketplace.load({ await marketplace.load({
category: category.value, category: category.value,
@@ -1100,6 +1115,14 @@ onMounted(async () => {
{{ phone.t('Apps.citymarkt.phone') }}: {{ phone.t('Apps.citymarkt.phone') }}:
{{ selectedListing.phone_number }} {{ selectedListing.phone_number }}
</p> </p>
<button
v-if="selectedListing.status === 'active' || selectedListing.status === 'reserved'"
class="citymarkt__pages-share"
type="button"
@click="shareListing"
>
<Share2 :size="17" /><span><strong>{{ phone.t('Apps.easyShare.share') }}</strong></span>
</button>
<template v-if="selectedListing.is_owner"> <template v-if="selectedListing.is_owner">
<button <button
v-if=" v-if="
+19
View File
@@ -56,6 +56,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
Send, Send,
Share2,
Settings2, Settings2,
Trash2, Trash2,
UserRound, UserRound,
@@ -75,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useCallsStore } from '@/stores/calls' import { useCallsStore } from '@/stores/calls'
import { useCompaniesStore } from '@/stores/companies' import { useCompaniesStore } from '@/stores/companies'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMessageMediaStore } from '@/stores/messageMedia' import { useMessageMediaStore } from '@/stores/messageMedia'
import { useMessagesStore } from '@/stores/messages' import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
@@ -130,6 +132,7 @@ const phone = usePhoneStore()
const calls = useCallsStore() const calls = useCallsStore()
const messages = useMessagesStore() const messages = useMessagesStore()
const companies = useCompaniesStore() const companies = useCompaniesStore()
const easyShare = useEasyShareStore()
const mediaPicker = useMessageMediaStore() const mediaPicker = useMessageMediaStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -494,6 +497,19 @@ async function setRoute(company: CompanySummary): Promise<void> {
showToast(phone.t('Apps.companies.routeSet')) showToast(phone.t('Apps.companies.routeSet'))
} }
function shareCompany(company: Company): void {
easyShare.open({
appId: 'companies',
copyText: `${company.name}\n${company.description}`,
id: company.id,
imageUrl: company.logoUrl,
kind: 'profile',
link: `skyphone://companies/profile/${company.id}`,
subtitle: company.phoneNumber ?? company.categoryName,
title: company.name,
})
}
function openRequestComposer(company: Company): void { function openRequestComposer(company: Company): void {
if (!company.acceptsRequests) { if (!company.acceptsRequests) {
showToast(phone.t('Apps.companies.actionUnavailable.request')) showToast(phone.t('Apps.companies.actionUnavailable.request'))
@@ -1618,6 +1634,9 @@ onBeforeUnmount(() => {
phone.t('Apps.companies.profile.request') phone.t('Apps.companies.profile.request')
}} }}
</k-button> </k-button>
<k-button rounded outline @click="shareCompany(activeCompany)">
<Share2 :size="17" />{{ phone.t('Apps.easyShare.share') }}
</k-button>
</k-glass> </k-glass>
</template> </template>
</section> </section>
+18
View File
@@ -27,6 +27,7 @@ import {
Navigation, Navigation,
Plane, Plane,
Route, Route,
Share2,
Sparkles, Sparkles,
Sailboat, Sailboat,
ShieldAlert, ShieldAlert,
@@ -37,6 +38,7 @@ import {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useGarageStore } from '@/stores/garage' import { useGarageStore } from '@/stores/garage'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { import type {
GarageVehicle, GarageVehicle,
@@ -50,6 +52,7 @@ type GarageFilter = 'all' | GarageVehicleStatus
const phone = usePhoneStore() const phone = usePhoneStore()
const garage = useGarageStore() const garage = useGarageStore()
const easyShare = useEasyShareStore()
const activeFilter = ref<GarageFilter>('all') const activeFilter = ref<GarageFilter>('all')
const query = ref('') const query = ref('')
const selectedVehicle = ref<GarageVehicle | null>(null) const selectedVehicle = ref<GarageVehicle | null>(null)
@@ -110,6 +113,18 @@ function displayName(vehicle: GarageVehicle): string {
return phone.t('Apps.garage.unknownVehicle') return phone.t('Apps.garage.unknownVehicle')
} }
function shareVehicle(vehicle: GarageVehicle): void {
easyShare.open({
appId: 'garage',
copyText: `${displayName(vehicle)}\n${vehicle.plate}`,
id: vehicle.plate,
kind: 'document',
link: `skyphone://garage/vehicle/${vehicle.plate}`,
subtitle: vehicle.plate,
title: displayName(vehicle),
})
}
function modelName(vehicle: GarageVehicle): string { function modelName(vehicle: GarageVehicle): string {
if (vehicle.name && vehicle.nickname) return vehicle.name if (vehicle.name && vehicle.nickname) return vehicle.name
if (typeof vehicle.model === 'string' && vehicle.model) return vehicle.model if (typeof vehicle.model === 'string' && vehicle.model) return vehicle.model
@@ -480,6 +495,9 @@ onBeforeUnmount(() => {
<small>{{ phone.t('Apps.garage.vin') }}</small> <small>{{ phone.t('Apps.garage.vin') }}</small>
<strong>{{ selectedVehicle.vin }}</strong> <strong>{{ selectedVehicle.vin }}</strong>
</div> </div>
<k-button large rounded outline @click="shareVehicle(selectedVehicle)">
<Share2 :size="18" />{{ phone.t('Apps.easyShare.share') }}
</k-button>
</section> </section>
</k-sheet> </k-sheet>
<k-dialog <k-dialog
+24
View File
@@ -26,6 +26,7 @@ import {
LockOpen, LockOpen,
Plus, Plus,
Router, Router,
Share2,
UserRound, UserRound,
UsersRound, UsersRound,
WifiOff, WifiOff,
@@ -34,6 +35,7 @@ import {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useHousingStore } from '@/stores/housing' import { useHousingStore } from '@/stores/housing'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { import type {
HousingKey, HousingKey,
@@ -43,6 +45,7 @@ import type {
const phone = usePhoneStore() const phone = usePhoneStore()
const housing = useHousingStore() const housing = useHousingStore()
const easyShare = useEasyShareStore()
const selectedPropertyId = ref<string | null>(null) const selectedPropertyId = ref<string | null>(null)
const candidatesOpened = ref(false) const candidatesOpened = ref(false)
const revokeCandidate = ref<HousingKey | null>(null) const revokeCandidate = ref<HousingKey | null>(null)
@@ -195,6 +198,18 @@ function accessLabel(property: HousingProperty): string {
) )
} }
function shareProperty(property: HousingProperty): void {
easyShare.open({
appId: 'house',
copyText: property.name,
id: property.id,
kind: 'document',
link: `skyphone://house/property/${property.id}`,
subtitle: accessLabel(property),
title: property.name,
})
}
onMounted(() => { onMounted(() => {
void housing.load() void housing.load()
}) })
@@ -413,6 +428,15 @@ onBeforeUnmount(() => {
<Camera :size="23" /> <Camera :size="23" />
<span>{{ phone.t('Apps.house.viewCamera') }}</span> <span>{{ phone.t('Apps.house.viewCamera') }}</span>
</k-glass> </k-glass>
<k-glass
:highlight="false"
component="button"
type="button"
@click="shareProperty(selectedProperty)"
>
<Share2 :size="23" />
<span>{{ phone.t('Apps.easyShare.share') }}</span>
</k-glass>
</div> </div>
<k-list inset strong class="house-facts"> <k-list inset strong class="house-facts">
+17 -1
View File
@@ -11,6 +11,7 @@ import {
Images, Images,
MapPin, MapPin,
Plus, Plus,
Share2,
Store, Store,
Trash2, Trash2,
UserRound, UserRound,
@@ -34,6 +35,7 @@ import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import { useAccountStore } from '@/stores/account' import { useAccountStore } from '@/stores/account'
import { useMessageMediaStore } from '@/stores/messageMedia' import { useMessageMediaStore } from '@/stores/messageMedia'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePagesStore } from '@/stores/pages' import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages' import type { PagesCategory, PagesPost } from '@/types/pages'
@@ -54,6 +56,7 @@ type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore() const phone = usePhoneStore()
const account = useAccountStore() const account = useAccountStore()
const messageMedia = useMessageMediaStore() const messageMedia = useMessageMediaStore()
const easyShare = useEasyShareStore()
const pages = usePagesStore() const pages = usePagesStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -263,6 +266,19 @@ function openCityMarktListing(): void {
}) })
} }
function sharePost(): void {
if (!selected.value) return
easyShare.open({
appId: 'local-pages',
copyText: `${selected.value.title}\n${selected.value.body}`,
id: selected.value.id,
kind: 'post',
link: `skyphone://local-pages/post/${selected.value.id}`,
subtitle: `@${selected.value.author_name}`,
title: selected.value.title,
})
}
onMounted(() => { onMounted(() => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose') const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
if (selection) { if (selection) {
@@ -390,7 +406,7 @@ onMounted(() => {
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div> <div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article> <article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
</div> </div>
<div class="pages__detail-actions"><button :class="{ active: selected.is_liked }" @click="react('like')"><Heart :size="19" :fill="selected.is_liked ? 'currentColor' : 'none'" />{{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}</button><button @click="react('save')"><Bookmark :size="19" :fill="selected.is_saved ? 'currentColor' : 'none'" />{{ phone.t('Apps.localPages.save') }}</button></div> <div class="pages__detail-actions"><button :class="{ active: selected.is_liked }" @click="react('like')"><Heart :size="19" :fill="selected.is_liked ? 'currentColor' : 'none'" />{{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}</button><button @click="sharePost"><Share2 :size="19" />{{ phone.t('Apps.easyShare.share') }}</button><button @click="react('save')"><Bookmark :size="19" :fill="selected.is_saved ? 'currentColor' : 'none'" />{{ phone.t('Apps.localPages.save') }}</button></div>
</section> </section>
<section v-else class="pages__compose"> <section v-else class="pages__compose">
+23
View File
@@ -14,6 +14,7 @@ import {
RotateCcw, RotateCcw,
Search, Search,
Send, Send,
Share2,
ShieldCheck, ShieldCheck,
SquarePen, SquarePen,
Trash2, Trash2,
@@ -26,6 +27,7 @@ import MailMarkdownEditor, {
type MailEditorLabels, type MailEditorLabels,
} from '@/components/MailMarkdownEditor.vue' } from '@/components/MailMarkdownEditor.vue'
import { useMailStore } from '@/stores/mail' import { useMailStore } from '@/stores/mail'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { import type {
MailComposeDraft, MailComposeDraft,
@@ -75,6 +77,7 @@ const MAIL_SWIPE_COMMIT_ANIMATION_MS = 180
const phone = usePhoneStore() const phone = usePhoneStore()
const mail = useMailStore() const mail = useMailStore()
const easyShare = useEasyShareStore()
const authMode = ref<AuthMode>('login') const authMode = ref<AuthMode>('login')
const authEmail = ref('') const authEmail = ref('')
const authPassword = ref('') const authPassword = ref('')
@@ -563,6 +566,19 @@ function composeForward(): void {
beginCompose(buildForwardDraft(selectedMessage.value)) beginCompose(buildForwardDraft(selectedMessage.value))
} }
function shareMessage(): void {
if (!selectedMessage.value) return
easyShare.open({
appId: 'mail',
copyText: `${selectedMessage.value.subject}\n${mailPlainText(selectedMessage.value.body)}`,
id: selectedMessage.value.id,
kind: 'document',
link: `skyphone://mail/message/${selectedMessage.value.id}`,
subtitle: selectedMessage.value.sender,
title: selectedMessage.value.subject || phone.t('Apps.mail.untitled'),
})
}
async function mutateSelected( async function mutateSelected(
endpoint: string, endpoint: string,
extra: Record<string, unknown> = {}, extra: Record<string, unknown> = {},
@@ -1004,6 +1020,13 @@ onBeforeUnmount(() => {
</article> </article>
<footer class="mail-action-bar"> <footer class="mail-action-bar">
<button
type="button"
:aria-label="phone.t('Apps.easyShare.share')"
@click="shareMessage"
>
<Share2 :size="20" />
</button>
<button <button
type="button" type="button"
:aria-label="phone.t('Apps.mail.reply')" :aria-label="phone.t('Apps.mail.reply')"
+158 -2
View File
@@ -3711,7 +3711,7 @@ const easyShareHistory = [
id: 'easyshare-outgoing-transferring', id: 'easyshare-outgoing-transferring',
otherName: 'Noah Walker', otherName: 'Noah Walker',
payload: { payload: {
appId: 'gallery', appId: 'photos',
copyText: 'Sunset over Los Santos.', copyText: 'Sunset over Los Santos.',
id: 3, id: 3,
imageUrl: imageUrl:
@@ -3801,7 +3801,7 @@ const easyShareHistory = [
id: 'easyshare-failed', id: 'easyshare-failed',
otherName: 'Noah Walker', otherName: 'Noah Walker',
payload: { payload: {
appId: 'gallery', appId: 'photos',
copyText: 'Vehicle walkaround video.', copyText: 'Vehicle walkaround video.',
id: 7, id: 7,
kind: 'video', kind: 'video',
@@ -3811,6 +3811,158 @@ const easyShareHistory = [
status: 'failed', status: 'failed',
}, },
] ]
const easyShareCatalog = [
{
appId: 'phone',
copyText: 'Mia Santos\n5550142',
id: 'contact-mia-santos',
kind: 'contact',
link: 'skyphone://phone/5550142',
subtitle: '5550142',
title: 'Mia Santos',
},
{
appId: 'calendar',
copyText: 'Downtown meetup\nBring the project notes.',
id: 'calendar-event-easyshare',
kind: 'document',
link: 'skyphone://calendar/event/calendar-event-easyshare',
subtitle: 'Tonight, 20:30',
title: 'Downtown meetup',
},
{
appId: 'citymarkt',
copyText: 'Comet Retro Custom in excellent condition.',
id: 'listing-easyshare-comet',
imageUrl: 'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
kind: 'link',
link: 'skyphone://citymarkt/listing/listing-easyshare-comet',
subtitle: '$84,000',
title: 'Comet Retro Custom',
},
{
appId: 'map',
copyText: 'Legion Square meeting point',
kind: 'location',
link: 'skyphone://location/current',
title: 'Legion Square',
},
{
appId: 'notes',
copyText: 'Check fuel, tires and radio before departure.',
id: 'note-easyshare-checklist',
kind: 'note',
title: 'Departure checklist',
},
{
appId: 'photos',
copyText: 'Sunset over Los Santos.',
id: 3,
imageUrl: 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
kind: 'photo',
link: 'skyphone://media/3',
title: 'Los Santos sunset',
},
{
appId: 'music',
copyText: 'Night Drive collection · 8 tracks',
id: 'playlist-easyshare-night-drive',
kind: 'playlist',
link: 'skyphone://music/playlist/playlist-easyshare-night-drive',
subtitle: '8 tracks',
title: 'Night Drive collection',
},
{
appId: 'local-pages',
copyText: 'Road closure\nAlta Street is closed until midnight.',
id: 'pages-post-easyshare-road-closure',
kind: 'post',
link: 'skyphone://local-pages/post/pages-post-easyshare-road-closure',
subtitle: '@nightshiftls',
title: 'Road closure',
},
{
appId: 'picstagram',
copyText: '@mia.santos',
id: 'picstagram-profile-easyshare-mia',
imageUrl: 'https://i.pravatar.cc/320?img=47',
kind: 'profile',
link: 'skyphone://picstagram/profile/picstagram-profile-easyshare-mia',
subtitle: '@mia.santos',
title: 'Mia Santos',
},
{
appId: 'darkchat',
copyText: 'Use the north entrance. The south gate is locked.',
id: 'darkchat-message-easyshare-entrance',
kind: 'text',
subtitle: 'NightOwl',
title: 'Use the north entrance',
},
{
appId: 'music',
copyText: 'Night Drive — Neon Coast',
id: 'night-drive',
imageUrl: 'https://picsum.photos/seed/easyshare-night-drive/720/720',
kind: 'track',
link: 'skyphone://music/server/night-drive',
subtitle: 'Neon Coast',
title: 'Night Drive',
},
{
appId: 'photos',
copyText: 'Vehicle walkaround video.',
id: 7,
imageUrl: 'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
kind: 'video',
link: 'skyphone://media/7',
title: 'Vehicle walkaround',
},
{
appId: 'companies',
copyText: 'Los Santos Customs\nRepairs, tuning and roadside support.',
id: 'mechanic',
kind: 'profile',
link: 'skyphone://companies/profile/mechanic',
subtitle: '555-MECH',
title: 'Los Santos Customs',
},
{
appId: 'mail',
copyText: 'Project handoff\nThe final checklist is attached below.',
id: 17,
kind: 'document',
link: 'skyphone://mail/message/17',
subtitle: 'mia@ifruit.com',
title: 'Project handoff',
},
{
appId: 'garage',
copyText: 'Comet Retro Custom\nSKY 2048',
id: 'SKY 2048',
kind: 'document',
link: 'skyphone://garage/vehicle/SKY%202048',
subtitle: 'SKY 2048',
title: 'Comet Retro Custom',
},
{
appId: 'house',
copyText: 'Vespucci Canals Apartment',
id: 'vespucci-apartment-4',
kind: 'document',
link: 'skyphone://house/property/vespucci-apartment-4',
subtitle: 'Owner',
title: 'Vespucci Canals Apartment',
},
].map((payload, index) => ({
createdAt: Date.now() - (index + 1) * 5 * 60 * 1000,
direction: index % 2 === 0 ? 'incoming' : 'outgoing',
id: `easyshare-catalog-${payload.kind}`,
otherName: index % 2 === 0 ? 'Mia Santos' : 'Noah Walker',
payload,
progress: 100,
status: 'completed',
}))
let easyShareVisibility = 'everyone' let easyShareVisibility = 'everyone'
function easyShareHistoryForScenario(testScenario) { function easyShareHistoryForScenario(testScenario) {
@@ -3825,6 +3977,10 @@ function easyShareHistoryForScenario(testScenario) {
(transfer) => !['pending', 'transferring'].includes(transfer.status), (transfer) => !['pending', 'transferring'].includes(transfer.status),
) )
} }
if (testScenario === 'easyshare-catalog') return easyShareCatalog
if (testScenario === 'easyshare-full') {
return [...easyShareHistory, ...easyShareCatalog]
}
return easyShareHistory return easyShareHistory
} }
+3
View File
@@ -194,6 +194,9 @@ Config.EasyShare = {
PendingSeconds = 30, PendingSeconds = 30,
TransferDurationMs = 3000, TransferDurationMs = 3000,
RequestsPerMinute = 12, RequestsPerMinute = 12,
BootstrapRequestsPerMinute = 30,
VisibilityUpdatesPerMinute = 10,
ActionsPerMinute = 30,
PayloadMaxBytes = 24000, PayloadMaxBytes = 24000,
} }
+442 -4
View File
@@ -17,12 +17,19 @@ local valid_kinds = {
video = true, video = true,
} }
local valid_apps = { local valid_apps = {
calendar = true,
camera = true, camera = true,
citymarkt = true,
companies = true,
crewlink = true, crewlink = true,
darkchat = true, darkchat = true,
feather = true, feather = true,
flare = true, flare = true,
fliptok = true, fliptok = true,
garage = true,
house = true,
["local-pages"] = true,
mail = true,
map = true, map = true,
messages = true, messages = true,
music = true, music = true,
@@ -33,6 +40,12 @@ local valid_apps = {
} }
local valid_visibilities = { contacts = true, everyone = true, hidden = true } local valid_visibilities = { contacts = true, everyone = true, hidden = true }
Bridge.Database.Query([[
UPDATE `sky_phone_easyshare_transfers`
SET `status` = 'expired', `updated_at` = CURRENT_TIMESTAMP, `completed_at` = CURRENT_TIMESTAMP
WHERE `status` IN ('pending', 'transferring')
]], {})
local function uuid() local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id local id = rows[1] and rows[1].id
@@ -79,6 +92,370 @@ local function append_params(target, values)
end end
end end
local function first_row(query, params)
local rows = Bridge.Database.Query(query, params)
return rows[1]
end
local function canonical_profile(device, app_id, id)
if app_id == "picstagram" then
local profile = first_row([[
SELECT p.`display_name`, p.`handle`, p.`bio`, media.`url` AS `image_url`
FROM `sky_phone_picstagram_profiles` p
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
WHERE p.`id` = ? AND p.`status` = 'active'
AND (p.`private` = 0 OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = p.`id`
) OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_follows` follow
WHERE follow.`following_id` = p.`id` AND follow.`status` = 'accepted'
AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
)) LIMIT 1
]], { id, device.imei, device.imei })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
imageUrl = profile.image_url,
link = "skyphone://picstagram/profile/" .. id,
}
end
elseif app_id == "feather" then
local profile = first_row([[
SELECT p.`display_name`, p.`handle`, media.`url` AS `image_url`
FROM `sky_phone_feather_profiles` p
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
WHERE p.`id` = ? LIMIT 1
]], { id })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
imageUrl = profile.image_url,
link = "skyphone://feather/profile/" .. id,
}
end
elseif app_id == "fliptok" then
local profile = first_row("SELECT `display_name`, `handle` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { id })
if profile then
return {
title = profile.display_name,
subtitle = "@" .. profile.handle,
copyText = "@" .. profile.handle,
link = "skyphone://fliptok/profile/" .. id,
}
end
elseif app_id == "flare" and device.account_id then
local profile = first_row([[
SELECT p.`name`, p.`age`, p.`bio`, media.`url` AS `image_url`
FROM `sky_phone_flare_profiles` p
LEFT JOIN `sky_phone_flare_profile_photos` photo ON photo.`profile_id` = p.`id` AND photo.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = photo.`media_id`
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
local title = ("%s, %s"):format(profile.name, profile.age)
return {
title = title,
copyText = title .. "\n" .. profile.bio,
imageUrl = profile.image_url,
link = "skyphone://flare/profile/" .. id,
}
end
elseif app_id == "crewlink" and device.account_id then
local profile = first_row([[
SELECT p.`username`, g.`name` AS `group_name`
FROM `sky_phone_crewlink_profiles` p
LEFT JOIN `sky_phone_crewlink_groups` g ON g.`id` = p.`active_group_id`
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
return {
title = "@" .. profile.username,
subtitle = profile.group_name,
copyText = "@" .. profile.username,
link = "skyphone://crewlink/profile/" .. id,
}
end
elseif app_id == "darkchat" and device.account_id then
local profile = first_row([[
SELECT `alias`, `dark_id`, `invite_code` FROM `sky_phone_darkchat_profiles`
WHERE `id` = ? AND `account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if profile then
return {
title = profile.alias,
subtitle = profile.dark_id,
copyText = profile.alias .. "\n" .. profile.dark_id .. "\n" .. profile.invite_code,
link = "skyphone://darkchat/invite/" .. profile.invite_code,
}
end
elseif app_id == "companies" then
local definition = Config.Companies.Enabled and Config.Companies.Definitions[id] or nil
if definition and definition.Public then
local profile = first_row([[
SELECT profile.`description`, media.`url` AS `image_url`
FROM `sky_phone_company_profiles` profile
LEFT JOIN `sky_phone_media` media ON media.`id` = profile.`logo_media_id`
WHERE profile.`company_id` = ? LIMIT 1
]], { id })
return {
title = definition.Name,
subtitle = definition.ServiceLine.Number,
copyText = definition.Name .. "\n" .. (profile and profile.description or definition.Description),
imageUrl = profile and profile.image_url or nil,
link = "skyphone://companies/profile/" .. id,
}
end
end
return nil
end
local function canonical_post(device, app_id, id)
if app_id == "picstagram" then
local post = first_row([[
SELECT post.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_picstagram_posts` post
JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id`
LEFT JOIN `sky_phone_picstagram_post_media` post_media
ON post_media.`post_id` = post.`id` AND post_media.`position` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
WHERE post.`id` = ? AND post.`status` = 'published' AND profile.`status` = 'active'
AND (profile.`private` = 0 OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
) OR EXISTS(
SELECT 1 FROM `sky_phone_picstagram_follows` follow
WHERE follow.`following_id` = profile.`id` AND follow.`status` = 'accepted'
AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
)) LIMIT 1
]], { id, device.imei, device.imei })
if post then
local title = post.caption ~= "" and post.caption or post.display_name
return {
title = title,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.caption,
imageUrl = post.image_url,
link = "skyphone://picstagram/post/" .. id,
}
end
elseif app_id == "feather" then
local post = first_row([[
SELECT post.`body`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_feather_posts` post
JOIN `sky_phone_feather_profiles` profile ON profile.`id` = post.`profile_id`
LEFT JOIN `sky_phone_feather_post_media` post_media
ON post_media.`post_id` = post.`id` AND post_media.`sort_order` = 0
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
WHERE post.`id` = ? AND post.`status` = 'published' LIMIT 1
]], { id })
if post then
return {
title = post.body,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.body,
imageUrl = post.image_url,
link = "skyphone://feather/post/" .. id,
}
end
elseif app_id == "fliptok" then
local post = first_row([[
SELECT video.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
FROM `sky_phone_fliptok_videos` video
JOIN `sky_phone_fliptok_profiles` profile ON profile.`id` = video.`profile_id`
JOIN `sky_phone_media` media ON media.`id` = video.`media_id`
WHERE video.`id` = ? AND video.`status` = 'published' AND (
video.`visibility` = 'public' OR EXISTS(
SELECT 1 FROM `sky_phone_fliptok_sessions` session
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
) OR (video.`visibility` = 'followers' AND EXISTS(
SELECT 1 FROM `sky_phone_fliptok_follows` follow
WHERE follow.`following_id` = profile.`id` AND follow.`follower_id` = (
SELECT session.`profile_id` FROM `sky_phone_fliptok_sessions` session
WHERE session.`device_imei` = ? LIMIT 1
)
))
) LIMIT 1
]], { id, device.imei, device.imei })
if post then
local title = post.caption ~= "" and post.caption or post.display_name
return {
title = title,
subtitle = "@" .. post.handle,
copyText = "@" .. post.handle .. ": " .. post.caption,
imageUrl = post.image_url,
link = "skyphone://fliptok/video/" .. id,
}
end
elseif app_id == "local-pages" then
local post = first_row([[
SELECT post.`title`, post.`body`, SUBSTRING_INDEX(account.`email`, '@', 1) AS `author_name`,
media.`url` AS `image_url`
FROM `sky_phone_pages_posts` post
JOIN `sky_phone_accounts` account ON account.`id` = post.`account_id`
LEFT JOIN `sky_phone_pages_images` image ON image.`post_id` = post.`id` AND image.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
WHERE post.`id` = ? LIMIT 1
]], { id })
if post then
return {
title = post.title,
subtitle = post.author_name,
copyText = post.title .. "\n" .. post.body,
imageUrl = post.image_url,
link = "skyphone://local-pages/post/" .. id,
}
end
elseif app_id == "citymarkt" then
local post = first_row([[
SELECT listing.`title`, listing.`description`, listing.`price`, listing.`price_type`, media.`url` AS `image_url`
FROM `sky_phone_marketplace_listings` listing
LEFT JOIN `sky_phone_marketplace_images` image
ON image.`listing_id` = listing.`id` AND image.`sort_order` = 1
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
WHERE listing.`id` = ? AND listing.`status` IN ('active', 'reserved') LIMIT 1
]], { id })
if post then
local price = post.price_type == "fixed" and tostring(post.price) or post.price_type
return {
title = post.title,
subtitle = price,
copyText = post.title .. "\n" .. post.description,
imageUrl = post.image_url,
link = "skyphone://citymarkt/listing/" .. id,
}
end
end
return nil
end
local function canonical_music(device, data)
if data.kind == "track" and type(data.meta) == "table" and data.meta.source == "server" then
for _, track in ipairs(Config.Music.Tracks) do
if track.Id == data.id then
return {
title = track.Title,
subtitle = track.Artist,
copyText = track.Title .. "" .. track.Artist,
link = "skyphone://music/server/" .. track.Id,
meta = { source = "server" },
}
end
end
elseif data.kind == "track" and type(data.meta) == "table" and data.meta.source == "youtube" then
local condition, params = owner_condition(device, "song")
local query_params = { data.id }
append_params(query_params, params)
local track = first_row(([=[
SELECT song.`title`, song.`artist`, song.`video_id`
FROM `sky_phone_music_youtube_songs` song
WHERE song.`id` = ? AND %s LIMIT 1
]=]):format(condition), query_params)
if track then
return {
title = track.title,
subtitle = track.artist,
copyText = track.title .. "" .. track.artist,
imageUrl = "https://i.ytimg.com/vi/" .. track.video_id .. "/hqdefault.jpg",
link = "skyphone://music/youtube/" .. data.id,
meta = { source = "youtube" },
}
end
elseif data.kind == "playlist" then
local condition, params = owner_condition(device, "playlist")
local query_params = { data.id }
append_params(query_params, params)
local playlist = first_row(([=[
SELECT playlist.`name`, COUNT(item.`id`) AS `song_count`
FROM `sky_phone_music_playlists` playlist
LEFT JOIN `sky_phone_music_playlist_items` item ON item.`playlist_id` = playlist.`id`
WHERE playlist.`id` = ? AND %s
GROUP BY playlist.`id`, playlist.`name` LIMIT 1
]=]):format(condition), query_params)
if playlist then
return {
title = playlist.name,
subtitle = tostring(playlist.song_count) .. " tracks",
copyText = playlist.name .. " · " .. tostring(playlist.song_count),
link = "skyphone://music/playlist/" .. data.id,
}
end
end
return nil
end
local function canonical_document(source, device, app_id, id)
if app_id == "calendar" and device.account_id then
local event = first_row([[
SELECT `title`, `note`, `starts_at`, `ends_at` FROM `sky_phone_calendar_events`
WHERE `id` = ? AND `account_id` = ? LIMIT 1
]], { id, tonumber(device.account_id) })
if event then
return {
title = event.title,
subtitle = tostring(event.starts_at),
copyText = event.title .. "\n" .. event.note,
link = "skyphone://calendar/event/" .. id,
meta = { startAt = tostring(event.starts_at), endAt = tostring(event.ends_at) },
}
end
elseif app_id == "mail" and device.account_id then
local message = first_row([[
SELECT message.`subject`, message.`body`, sender.`email` AS `sender_email`
FROM `sky_phone_mail_entries` entry
JOIN `sky_phone_mail_messages` message ON message.`id` = entry.`message_id`
LEFT JOIN `sky_phone_accounts` sender ON sender.`id` = message.`sender_account_id`
WHERE entry.`id` = ? AND entry.`account_id` = ? AND entry.`trashed_at` IS NULL LIMIT 1
]], { id, tonumber(device.account_id) })
if message then
return {
title = message.subject,
subtitle = message.sender_email,
copyText = message.subject .. "\n" .. message.body,
link = "skyphone://mail/message/" .. id,
}
end
elseif app_id == "garage" then
return SkyPhoneGarage.ResolveShare(source, id)
elseif app_id == "house" then
return SkyPhoneHousing.ResolveShare(source, id)
end
return nil
end
local function canonical_text(device, app_id, id)
if app_id ~= "darkchat" or not device.account_id then
return nil
end
local message = first_row([[
SELECT message.`body`, message.`message_type`, sender.`alias` AS `sender_alias`
FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
JOIN `sky_phone_darkchat_profiles` viewer ON viewer.`id` = member.`profile_id`
LEFT JOIN `sky_phone_darkchat_profiles` sender ON sender.`id` = message.`sender_profile_id`
WHERE message.`id` = ? AND viewer.`account_id` = ? AND message.`deleted_for_everyone` = 0
AND message.`message_type` IN ('text', 'emoji') LIMIT 1
]], { id, tonumber(device.account_id) })
if not message then
return nil
end
return {
title = message.body,
subtitle = message.sender_alias,
copyText = message.body,
}
end
local function display_name(source) local function display_name(source)
local first = trim(Bridge.Framework.GetFirstname(source), 80) local first = trim(Bridge.Framework.GetFirstname(source), 80)
local last = trim(Bridge.Framework.GetLastname(source), 80) local last = trim(Bridge.Framework.GetLastname(source), 80)
@@ -250,8 +627,30 @@ local function sanitize_payload(source, device, data)
end end
local coords = GetEntityCoords(ped) local coords = GetEntityCoords(ped)
payload.meta = { x = coords.x, y = coords.y, z = coords.z } 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 else
payload.imageUrl = data.imageUrl local canonical
if data.kind == "profile" and payload.id then
canonical = canonical_profile(device, app_id, payload.id)
elseif data.kind == "post" and payload.id then
canonical = canonical_post(device, app_id, payload.id)
elseif (data.kind == "track" or data.kind == "playlist") and app_id == "music" and payload.id then
canonical = canonical_music(device, data)
elseif data.kind == "document" and payload.id then
canonical = canonical_document(source, device, app_id, payload.id)
elseif data.kind == "text" and payload.id then
canonical = canonical_text(device, app_id, payload.id)
elseif data.kind == "link" and payload.id and (app_id == "citymarkt" or app_id == "local-pages") then
canonical = canonical_post(device, app_id, payload.id)
end
if not canonical then
return nil, "unsupported_payload"
end
payload.title = canonical.title
payload.copyText = canonical.copyText
payload.subtitle = canonical.subtitle
payload.imageUrl = canonical.imageUrl
payload.link = canonical.link
payload.meta = canonical.meta
end end
local encoded = json.encode(payload) local encoded = json.encode(payload)
@@ -262,6 +661,9 @@ local function sanitize_payload(source, device, data)
end end
function SkyPhoneEasyShare.SanitizeChatPayload(source, data) function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
if not Config.EasyShare.Enabled then
return nil, "disabled"
end
local device, error_response = current_device(source) local device, error_response = current_device(source)
if not device then if not device then
return nil, error_response.error return nil, error_response.error
@@ -328,12 +730,15 @@ local function apply_received_payload(transfer)
meta.name, meta.notes or "", meta.organization or "", meta.phoneNumber, meta.name, meta.notes or "", meta.organization or "", meta.phoneNumber,
}) })
TriggerClientEvent("sky_phone:contacts:changed", transfer.recipient_source, {}) TriggerClientEvent("sky_phone:contacts:changed", transfer.recipient_source, {})
elseif transfer.payload.kind == "note" then elseif transfer.payload.kind == "note" or transfer.payload.kind == "text" or transfer.payload.kind == "document" then
Bridge.Database.Query([[ Bridge.Database.Query([[
INSERT INTO `sky_phone_notes` INSERT INTO `sky_phone_notes`
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`) (`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
VALUES (?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, 0)
]], { uuid(), account_id, account_id and nil or device.imei, meta.title or "", meta.body or "" }) ]], {
uuid(), account_id, account_id and nil or device.imei,
meta.title or transfer.payload.title, meta.body or transfer.payload.copyText,
})
if account_id then if account_id then
SkyPhone.RefreshAccount(account_id) SkyPhone.RefreshAccount(account_id)
else else
@@ -353,6 +758,15 @@ local function apply_received_payload(transfer)
account_id, account_id and nil or device.imei, meta.url, meta.remoteId, transfer.payload.kind, account_id, account_id and nil or device.imei, meta.url, meta.remoteId, transfer.payload.kind,
}) })
TriggerClientEvent("sky_phone:gallery:changed", transfer.recipient_source, {}) TriggerClientEvent("sky_phone:gallery:changed", transfer.recipient_source, {})
elseif transfer.payload.kind == "post"
or transfer.payload.kind == "profile"
or transfer.payload.kind == "track"
or transfer.payload.kind == "playlist"
or transfer.payload.kind == "link"
then
return type(transfer.payload.link) == "string"
else
return false
end end
return true return true
end end
@@ -415,6 +829,9 @@ Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
if not Config.EasyShare.Enabled then if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" } return { success = false, error = "disabled" }
end end
if not SkyPhone.AllowOperation(source, "easyshare_bootstrap", Config.EasyShare.BootstrapRequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local device, error_response = current_device(source) local device, error_response = current_device(source)
if not device then if not device then
return error_response return error_response
@@ -437,6 +854,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
end) end)
Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data) Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_visibility", Config.EasyShare.VisibilityUpdatesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local device, error_response = current_device(source) local device, error_response = current_device(source)
if not device then if not device then
return error_response return error_response
@@ -453,6 +876,9 @@ Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source,
end) end)
Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data) Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_request", Config.EasyShare.RequestsPerMinute, 60) then if not SkyPhone.AllowOperation(source, "easyshare_request", Config.EasyShare.RequestsPerMinute, 60) then
return { success = false, error = "rate_limited" } return { success = false, error = "rate_limited" }
end end
@@ -503,6 +929,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
end) end)
Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data) Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local id = type(data) == "table" and data.id or nil local id = type(data) == "table" and data.id or nil
local transfer = type(id) == "string" and active_transfers[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 if not transfer or transfer.recipient_source ~= source or transfer.status ~= "pending" or type(data.accepted) ~= "boolean" then
@@ -527,6 +959,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
end) end)
Bridge.Callbacks.Register("sky_phone:easyshare:cancel", function(source, data) Bridge.Callbacks.Register("sky_phone:easyshare:cancel", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
end
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local id = type(data) == "table" and data.id or nil local id = type(data) == "table" and data.id or nil
local transfer = type(id) == "string" and active_transfers[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 if not transfer or (transfer.sender_source ~= source and transfer.recipient_source ~= source) then
+26
View File
@@ -1,3 +1,5 @@
SkyPhoneGarage = {}
Bridge.Database.AfterMigration("sky_phone", function() Bridge.Database.AfterMigration("sky_phone", function()
local supported_systems = { local supported_systems = {
@@ -200,6 +202,30 @@ local function owned_vehicle_row(identifier, plate)
return rows[1], table_name, owner_column, garage_system return rows[1], table_name, owner_column, garage_system
end end
function SkyPhoneGarage.ResolveShare(source, plate_value)
local plate = normalized_plate(plate_value)
if not plate then
return nil
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil
end
local row, _, _, garage_system = owned_vehicle_row(identifier, plate)
if not row then
return nil
end
local vehicle = vehicle_dto(row, garage_system)
local title = vehicle.nickname ~= "" and vehicle.nickname or vehicle.plate
return {
title = title,
subtitle = vehicle.plate,
copyText = title .. "\n" .. vehicle.plate .. " · " .. vehicle.status,
link = "skyphone://garage/vehicle/" .. vehicle.plate,
meta = { kind = vehicle.kind, location = vehicle.location, status = vehicle.status },
}
end
local function status_snapshot(row) local function status_snapshot(row)
local snapshot = {} local snapshot = {}
for _, column in ipairs({ "stored", "state", "in_garage", "parked" }) do for _, column in ipairs({ "stored", "state", "in_garage", "parked" }) do
+21
View File
@@ -1,3 +1,24 @@
SkyPhoneHousing = {}
function SkyPhoneHousing.ResolveShare(source, property_id)
local overview = Bridge.Housing.GetOverview(source)
if not overview or type(overview.properties) ~= "table" then
return nil
end
for _, property in ipairs(overview.properties) do
if property.id == property_id then
return {
title = property.name,
subtitle = property.access,
copyText = property.name,
link = "skyphone://house/property/" .. property.id,
meta = { access = property.access, entrance = property.entrance },
}
end
end
return nil
end
Bridge.Callbacks.Register("sky_phone:housing:overview", function(source) Bridge.Callbacks.Register("sky_phone:housing:overview", function(source)
if not SkyPhone.AllowOperation( if not SkyPhone.AllowOperation(
source, source,