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:
- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired,
and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/gallery`
- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/gallery`
- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/gallery`
- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/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/photos`
- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/photos`
- 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
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.
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.
+45 -28
View File
@@ -1,5 +1,5 @@
<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 {
Check,
Clock3,
@@ -27,6 +27,7 @@ import type {
EasyShareTransfer,
EasyShareVisibility,
} from '@/types/easyshare'
import { openEasySharePayload } from '@/utils/easyshare'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
@@ -44,7 +45,7 @@ let dragPointerId: number | null = null
let dragStartTime = 0
let dragStartY = 0
const visibilityOptions: EasyShareVisibility[] = ['everyone', 'contacts', 'hidden']
type EasyShareDestinationAppId = 'darkchat' | 'messages' | 'notes'
type EasyShareDestinationAppId = 'darkchat' | 'flare' | 'messages' | 'notes'
const app = computed(() =>
easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined,
)
@@ -57,7 +58,7 @@ const sharePeople = computed(() => {
}> = []
const phoneNumbers = new Set<string>()
if (easyShare.payload?.appId === 'flare') {
if (!appStore.homeLayout.hidden.includes('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
@@ -107,6 +108,7 @@ const sharePeople = computed(() => {
const shareAppIds: EasyShareDestinationAppId[] = [
'messages',
'darkchat',
'flare',
'notes',
]
const shareApps = computed(() =>
@@ -193,14 +195,14 @@ function shareToChat(kind: EasyShareChatApp, targetId: string): void {
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: 'darkchat' | 'messages'): void {
function openChatApp(kind: EasyShareChatApp): void {
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationAppId): void {
if (appId === 'messages' || appId === 'darkchat') {
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
}
@@ -235,6 +237,11 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
feedback.value = label('errors.request_failed')
}
}
async function openTransfer(transfer: EasyShareTransfer): Promise<void> {
close()
await openEasySharePayload(router, transfer.payload)
}
</script>
<template>
@@ -329,18 +336,19 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
<strong>{{ label('nearby') }}</strong>
<k-link component="button" @click="easyShare.showHistory"><History :size="18" /></k-link>
</div>
<div class="easyshare-visibility">
<ShieldCheck :size="18" />
<select
<k-list inset strong class="easyshare-visibility">
<k-list-input
type="select"
:label="label('visibility')"
:value="easyShare.visibility"
:aria-label="label('visibility')"
@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">
{{ label(`visibilityOptions.${option}`) }}
</option>
</select>
</div>
</k-list-input>
</k-list>
<k-glass v-if="easyShare.incomingTransfer" class="easyshare-incoming">
<UserRound :size="28" />
@@ -369,13 +377,19 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
</k-glass>
<div v-if="easyShare.loading" class="easyshare-loading"><k-preloader /></div>
<div v-else-if="easyShare.targets.length" class="easyshare-targets">
<button v-for="target in easyShare.targets" :key="target.id" type="button" @click="requestTransfer(target.id)">
<span><UserRound /></span>
<div><strong>{{ target.name }}</strong><small>{{ label('distance', { distance: String(target.distance) }) }}</small></div>
<Share2 :size="18" />
</button>
</div>
<k-list v-else-if="easyShare.targets.length" inset strong class="easyshare-targets">
<k-list-item
v-for="target in easyShare.targets"
:key="target.id"
link
:title="target.name"
: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>
</section>
@@ -385,17 +399,20 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise<void> {
<strong>{{ label('history') }}</strong>
<span></span>
</div>
<div class="easyshare-history">
<article v-for="transfer in easyShare.history" :key="transfer.id">
<span><Clock3 /></span>
<div>
<strong>{{ transfer.payload.title }}</strong>
<small>{{ transfer.otherName }} · {{ statusLabel(transfer) }}</small>
</div>
<small>{{ transfer.direction === 'incoming' ? '↓' : '↑' }}</small>
</article>
<k-list inset strong class="easyshare-history">
<k-list-item
v-for="transfer in easyShare.history"
:key="transfer.id"
link
:title="transfer.payload.title"
:subtitle="`${transfer.otherName} · ${statusLabel(transfer)}`"
@click="openTransfer(transfer)"
>
<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>
</div>
</k-list>
</section>
<p v-if="feedback" class="easyshare-feedback">{{ feedback }}</p>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { EasySharePayload } from '@/types/easyshare'
import { openEasySharePayload } from '@/utils/easyshare'
const props = withDefaults(
defineProps<{
@@ -15,6 +17,7 @@ const props = withDefaults(
{ compact: false, variant: 'messages' },
)
const phone = usePhoneStore()
const router = useRouter()
const imageFailed = ref(false)
const sourceApp = computed(() => getPhoneApp(props.payload.appId))
const isVideo = computed(
@@ -47,6 +50,11 @@ watch(
`shared-content-card--${variant}`,
{ '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">
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
@@ -92,6 +100,12 @@ watch(
color: #171719;
background: rgb(255 255 255 / 96%);
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 {
+2
View File
@@ -353,6 +353,7 @@ const defaultLocales: LocaleTree = {
destinations: 'Share destinations',
newMessage: 'New Message',
shareProfile: 'Share Profile',
share: 'Share',
kinds: {
contact: 'Contact',
document: 'Document',
@@ -404,6 +405,7 @@ const defaultLocales: LocaleTree = {
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.',
unsupported_payload: 'This item is not available for EasyShare.',
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.',
+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,
Rows3,
Search,
Share2,
Trash2,
UserRound,
X,
@@ -22,6 +23,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useAccountStore } from '@/stores/account'
import { useCalendarStore } from '@/stores/calendar'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
@@ -42,6 +44,7 @@ type YearOverview = {
const phone = usePhoneStore()
const account = useAccountStore()
const calendar = useCalendarStore()
const easyShare = useEasyShareStore()
const today = new Date()
const visibleYear = ref(today.getFullYear())
const selectedDate = ref(dateKey(today))
@@ -358,6 +361,19 @@ function openEdit(): void {
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 {
timePickerField.value = null
screen.value = editingEvent.value ? 'detail' : 'main'
@@ -781,6 +797,10 @@ onMounted(async () => {
<p>{{ selectedEvent.note }}</p>
</div>
</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">
<Trash2 :size="18" />
{{ phone.t('Apps.calendar.deleteEvent') }}
@@ -1739,6 +1759,10 @@ onMounted(async () => {
font-size: 14px !important;
}
.calendar__share {
color: #0a84ff !important;
}
.calendar__group--fields input,
.calendar__group--fields textarea {
width: 100%;
+23
View File
@@ -50,6 +50,7 @@ import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
import { useAccountStore } from '@/stores/account'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
@@ -115,6 +116,7 @@ const phone = usePhoneStore()
const route = useRoute()
const router = useRouter()
const account = useAccountStore()
const easyShare = useEasyShareStore()
const marketplace = useMarketplaceStore()
const messageMedia = useMessageMediaStore()
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> {
await marketplace.load({
category: category.value,
@@ -1100,6 +1115,14 @@ onMounted(async () => {
{{ phone.t('Apps.citymarkt.phone') }}:
{{ selectedListing.phone_number }}
</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">
<button
v-if="
+19
View File
@@ -56,6 +56,7 @@ import {
Plus,
RefreshCw,
Send,
Share2,
Settings2,
Trash2,
UserRound,
@@ -75,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useCallsStore } from '@/stores/calls'
import { useCompaniesStore } from '@/stores/companies'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
@@ -130,6 +132,7 @@ const phone = usePhoneStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const companies = useCompaniesStore()
const easyShare = useEasyShareStore()
const mediaPicker = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
@@ -494,6 +497,19 @@ async function setRoute(company: CompanySummary): Promise<void> {
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 {
if (!company.acceptsRequests) {
showToast(phone.t('Apps.companies.actionUnavailable.request'))
@@ -1618,6 +1634,9 @@ onBeforeUnmount(() => {
phone.t('Apps.companies.profile.request')
}}
</k-button>
<k-button rounded outline @click="shareCompany(activeCompany)">
<Share2 :size="17" />{{ phone.t('Apps.easyShare.share') }}
</k-button>
</k-glass>
</template>
</section>
+18
View File
@@ -27,6 +27,7 @@ import {
Navigation,
Plane,
Route,
Share2,
Sparkles,
Sailboat,
ShieldAlert,
@@ -37,6 +38,7 @@ import {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useGarageStore } from '@/stores/garage'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type {
GarageVehicle,
@@ -50,6 +52,7 @@ type GarageFilter = 'all' | GarageVehicleStatus
const phone = usePhoneStore()
const garage = useGarageStore()
const easyShare = useEasyShareStore()
const activeFilter = ref<GarageFilter>('all')
const query = ref('')
const selectedVehicle = ref<GarageVehicle | null>(null)
@@ -110,6 +113,18 @@ function displayName(vehicle: GarageVehicle): string {
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 {
if (vehicle.name && vehicle.nickname) return vehicle.name
if (typeof vehicle.model === 'string' && vehicle.model) return vehicle.model
@@ -480,6 +495,9 @@ onBeforeUnmount(() => {
<small>{{ phone.t('Apps.garage.vin') }}</small>
<strong>{{ selectedVehicle.vin }}</strong>
</div>
<k-button large rounded outline @click="shareVehicle(selectedVehicle)">
<Share2 :size="18" />{{ phone.t('Apps.easyShare.share') }}
</k-button>
</section>
</k-sheet>
<k-dialog
+24
View File
@@ -26,6 +26,7 @@ import {
LockOpen,
Plus,
Router,
Share2,
UserRound,
UsersRound,
WifiOff,
@@ -34,6 +35,7 @@ import {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useHousingStore } from '@/stores/housing'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type {
HousingKey,
@@ -43,6 +45,7 @@ import type {
const phone = usePhoneStore()
const housing = useHousingStore()
const easyShare = useEasyShareStore()
const selectedPropertyId = ref<string | null>(null)
const candidatesOpened = ref(false)
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(() => {
void housing.load()
})
@@ -413,6 +428,15 @@ onBeforeUnmount(() => {
<Camera :size="23" />
<span>{{ phone.t('Apps.house.viewCamera') }}</span>
</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>
<k-list inset strong class="house-facts">
+17 -1
View File
@@ -11,6 +11,7 @@ import {
Images,
MapPin,
Plus,
Share2,
Store,
Trash2,
UserRound,
@@ -34,6 +35,7 @@ import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import { useAccountStore } from '@/stores/account'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages'
@@ -54,6 +56,7 @@ type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore()
const account = useAccountStore()
const messageMedia = useMessageMediaStore()
const easyShare = useEasyShareStore()
const pages = usePagesStore()
const route = useRoute()
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(() => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
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>
<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 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 v-else class="pages__compose">
+23
View File
@@ -14,6 +14,7 @@ import {
RotateCcw,
Search,
Send,
Share2,
ShieldCheck,
SquarePen,
Trash2,
@@ -26,6 +27,7 @@ import MailMarkdownEditor, {
type MailEditorLabels,
} from '@/components/MailMarkdownEditor.vue'
import { useMailStore } from '@/stores/mail'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type {
MailComposeDraft,
@@ -75,6 +77,7 @@ const MAIL_SWIPE_COMMIT_ANIMATION_MS = 180
const phone = usePhoneStore()
const mail = useMailStore()
const easyShare = useEasyShareStore()
const authMode = ref<AuthMode>('login')
const authEmail = ref('')
const authPassword = ref('')
@@ -563,6 +566,19 @@ function composeForward(): void {
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(
endpoint: string,
extra: Record<string, unknown> = {},
@@ -1004,6 +1020,13 @@ onBeforeUnmount(() => {
</article>
<footer class="mail-action-bar">
<button
type="button"
:aria-label="phone.t('Apps.easyShare.share')"
@click="shareMessage"
>
<Share2 :size="20" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.mail.reply')"
+158 -2
View File
@@ -3711,7 +3711,7 @@ const easyShareHistory = [
id: 'easyshare-outgoing-transferring',
otherName: 'Noah Walker',
payload: {
appId: 'gallery',
appId: 'photos',
copyText: 'Sunset over Los Santos.',
id: 3,
imageUrl:
@@ -3801,7 +3801,7 @@ const easyShareHistory = [
id: 'easyshare-failed',
otherName: 'Noah Walker',
payload: {
appId: 'gallery',
appId: 'photos',
copyText: 'Vehicle walkaround video.',
id: 7,
kind: 'video',
@@ -3811,6 +3811,158 @@ const easyShareHistory = [
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'
function easyShareHistoryForScenario(testScenario) {
@@ -3825,6 +3977,10 @@ function easyShareHistoryForScenario(testScenario) {
(transfer) => !['pending', 'transferring'].includes(transfer.status),
)
}
if (testScenario === 'easyshare-catalog') return easyShareCatalog
if (testScenario === 'easyshare-full') {
return [...easyShareHistory, ...easyShareCatalog]
}
return easyShareHistory
}
+3
View File
@@ -194,6 +194,9 @@ Config.EasyShare = {
PendingSeconds = 30,
TransferDurationMs = 3000,
RequestsPerMinute = 12,
BootstrapRequestsPerMinute = 30,
VisibilityUpdatesPerMinute = 10,
ActionsPerMinute = 30,
PayloadMaxBytes = 24000,
}
+442 -4
View File
@@ -17,12 +17,19 @@ local valid_kinds = {
video = true,
}
local valid_apps = {
calendar = true,
camera = true,
citymarkt = true,
companies = true,
crewlink = true,
darkchat = true,
feather = true,
flare = true,
fliptok = true,
garage = true,
house = true,
["local-pages"] = true,
mail = true,
map = true,
messages = true,
music = true,
@@ -33,6 +40,12 @@ local valid_apps = {
}
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 rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id
@@ -79,6 +92,370 @@ local function append_params(target, values)
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 first = trim(Bridge.Framework.GetFirstname(source), 80)
local last = trim(Bridge.Framework.GetLastname(source), 80)
@@ -250,8 +627,30 @@ local function sanitize_payload(source, device, data)
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
else
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
local encoded = json.encode(payload)
@@ -262,6 +661,9 @@ local function sanitize_payload(source, device, data)
end
function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
if not Config.EasyShare.Enabled then
return nil, "disabled"
end
local device, error_response = current_device(source)
if not device then
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,
})
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([[
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 "" })
]], {
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
SkyPhone.RefreshAccount(account_id)
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,
})
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
return true
end
@@ -415,6 +829,9 @@ Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
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)
if not device then
return error_response
@@ -437,6 +854,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
end)
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)
if not device then
return error_response
@@ -453,6 +876,9 @@ Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source,
end)
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
return { success = false, error = "rate_limited" }
end
@@ -503,6 +929,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
end)
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 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
@@ -527,6 +959,12 @@ Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
end)
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 transfer = type(id) == "string" and active_transfers[id] or nil
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()
local supported_systems = {
@@ -200,6 +202,30 @@ local function owned_vehicle_row(identifier, plate)
return rows[1], table_name, owner_column, garage_system
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 snapshot = {}
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)
if not SkyPhone.AllowOperation(
source,