From 42ac955052d5d5353752e27ad6fceedf0935d746 Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt"
Date: Tue, 11 Aug 2026 00:22:39 +0200
Subject: [PATCH 01/63] ADD - expand phone application platform
Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
---
README.md | 10 +
frontend/src/App.vue | 182 +-
.../src/assets/img/app-icons/companies.svg | 29 +
frontend/src/assets/main.css | 10 +
frontend/src/components/AppIcon.vue | 33 +-
frontend/src/components/CustomAppFrame.vue | 421 +++
.../components/NotificationPhonePreview.vue | 2 +
frontend/src/components/PayphoneOverlay.vue | 2 +
frontend/src/components/PhoneLockScreen.vue | 22 +-
frontend/src/components/PhoneMediaCapture.vue | 2 +
.../src/components/PhoneNotifications.vue | 13 +
frontend/src/components/RadioHud.vue | 2 +
frontend/src/config/apps.test.ts | 7 +
frontend/src/config/apps.ts | 65 +-
frontend/src/stores/app-catalog.test.ts | 135 +
frontend/src/stores/app-catalog.ts | 314 ++
frontend/src/stores/app-store.test.ts | 78 +-
frontend/src/stores/app-store.ts | 185 +-
frontend/src/stores/companies.test.ts | 327 ++
frontend/src/stores/companies.ts | 539 +++
frontend/src/stores/notifications.test.ts | 15 +-
frontend/src/stores/notifications.ts | 51 +-
frontend/src/stores/phone.ts | 329 +-
frontend/src/types/apps.ts | 112 +-
frontend/src/types/companies.ts | 271 ++
frontend/src/types/phone.ts | 7 +
frontend/src/utils/customAppBridge.test.ts | 268 ++
frontend/src/utils/customAppBridge.ts | 512 +++
frontend/src/utils/customAppLifecycle.test.ts | 229 ++
frontend/src/utils/customAppLifecycle.ts | 183 +
frontend/src/utils/homeLayout.test.ts | 26 +-
frontend/src/utils/homeLayout.ts | 30 +-
frontend/src/utils/nui.ts | 2 +-
frontend/src/utils/preferences.test.ts | 22 +
frontend/src/utils/preferences.ts | 50 +-
frontend/src/utils/windowMessages.test.ts | 19 +
frontend/src/utils/windowMessages.ts | 6 +
frontend/src/views/PhoneAppWindow.vue | 13 +-
frontend/src/views/SpringboardView.vue | 29 +-
frontend/src/views/apps/AppStoreApp.vue | 26 +-
frontend/src/views/apps/BillingApp.vue | 2 +
frontend/src/views/apps/CameraApp.vue | 2 +
frontend/src/views/apps/CompaniesApp.vue | 3331 +++++++++++++++++
frontend/src/views/apps/CrewLinkApp.vue | 2 +
frontend/src/views/apps/GalleryApp.vue | 2 +
frontend/src/views/apps/GarageApp.vue | 2 +
frontend/src/views/apps/MailApp.vue | 2 +
frontend/src/views/apps/MessagesApp.vue | 86 +-
frontend/src/views/apps/PhoneApp.vue | 36 +-
frontend/src/views/apps/RadioApp.vue | 2 +
frontend/src/views/apps/SettingsApp.vue | 18 +-
frontend/src/views/apps/SkyRideApp.vue | 2 +
frontend/testserver/index.cjs | 960 ++++-
sky_phone/config/companies.lua | 248 ++
sky_phone/config/config.lua | 17 +
sky_phone/config/locales/en.lua | 315 +-
sky_phone/custom_apps/_sdk/default-icon.svg | 13 +
sky_phone/custom_apps/_sdk/sky-phone-app.d.ts | 74 +
sky_phone/custom_apps/_sdk/sky-phone-app.js | 211 ++
sky_phone/fxmanifest.lua | 21 +
sky_phone/source/client/custom_app_compat.lua | 581 +++
sky_phone/source/client/custom_apps.lua | 1396 +++++++
sky_phone/source/client/main.lua | 39 +
sky_phone/source/server/calls.lua | 720 +++-
sky_phone/source/server/companies.lua | 3028 +++++++++++++++
sky_phone/source/server/custom_app_compat.lua | 138 +
.../source/server/custom_app_storage.lua | 271 ++
sky_phone/source/server/custom_apps.lua | 385 ++
sky_phone/source/server/db_migrate.lua | 286 ++
sky_phone/source/server/phone.lua | 25 +
sky_phone/source/server/sim.lua | 24 +
sky_phone/source/shared/custom_app_compat.lua | 229 ++
sky_phone/source/shared/custom_apps.lua | 451 +++
sky_phone/sql/install.sql | 178 +
tests/custom_app_compat.lua | 80 +
tests/custom_app_compat_client.lua | 140 +
tests/custom_app_compat_server.lua | 68 +
77 files changed, 17630 insertions(+), 333 deletions(-)
create mode 100644 frontend/src/assets/img/app-icons/companies.svg
create mode 100644 frontend/src/components/CustomAppFrame.vue
create mode 100644 frontend/src/stores/app-catalog.test.ts
create mode 100644 frontend/src/stores/app-catalog.ts
create mode 100644 frontend/src/stores/companies.test.ts
create mode 100644 frontend/src/stores/companies.ts
create mode 100644 frontend/src/types/companies.ts
create mode 100644 frontend/src/utils/customAppBridge.test.ts
create mode 100644 frontend/src/utils/customAppBridge.ts
create mode 100644 frontend/src/utils/customAppLifecycle.test.ts
create mode 100644 frontend/src/utils/customAppLifecycle.ts
create mode 100644 frontend/src/utils/windowMessages.test.ts
create mode 100644 frontend/src/utils/windowMessages.ts
create mode 100644 frontend/src/views/apps/CompaniesApp.vue
create mode 100644 sky_phone/config/companies.lua
create mode 100644 sky_phone/custom_apps/_sdk/default-icon.svg
create mode 100644 sky_phone/custom_apps/_sdk/sky-phone-app.d.ts
create mode 100644 sky_phone/custom_apps/_sdk/sky-phone-app.js
create mode 100644 sky_phone/source/client/custom_app_compat.lua
create mode 100644 sky_phone/source/client/custom_apps.lua
create mode 100644 sky_phone/source/server/companies.lua
create mode 100644 sky_phone/source/server/custom_app_compat.lua
create mode 100644 sky_phone/source/server/custom_app_storage.lua
create mode 100644 sky_phone/source/server/custom_apps.lua
create mode 100644 sky_phone/source/shared/custom_app_compat.lua
create mode 100644 sky_phone/source/shared/custom_apps.lua
create mode 100644 tests/custom_app_compat.lua
create mode 100644 tests/custom_app_compat_client.lua
create mode 100644 tests/custom_app_compat_server.lua
diff --git a/README.md b/README.md
index 90802cd..9786b89 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,15 @@
# sky_phone
+## Custom Apps
+
+`sky_phone` erkennt Registrierungen gestarteter Fremd-App-Ressourcen über integrierte
+Hersteller-Aliase und übernimmt unterstützte Apps automatisch in Springboard und App Store. In
+App-Ressourcen müssen dafür keine Sky-Vorlagen abgelegt werden. Unterstützt werden die
+dokumentierten Basisverträge von LB Phone, 17Movement, High Phone, Quasar Smartphone V3 und
+YSeries; die Aliase `lb-phone`, `17mov_Phone`, `high-phone`, `qs-smartphone` und `yseries` werden
+direkt von `sky_phone` bereitgestellt. Einrichtung und ehrliche Kompatibilitätsgrenzen stehen in der
+[deutschen Custom-App-Anleitung](docs/custom-apps.md).
+
## FlipTok verification
FlipTok verification is server-authoritative and limited to the framework groups configured in
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index b7c966a..dee09c2 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -29,6 +29,7 @@ import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useBillingStore } from '@/stores/billing'
+import { useCompaniesStore } from '@/stores/companies'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
@@ -39,6 +40,7 @@ import { usePicstagramStore } from '@/stores/picstagram'
import { useFeatherStore } from '@/stores/feather'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
+import { useAppCatalogStore } from '@/stores/app-catalog'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId } from '@/config/apps'
@@ -46,16 +48,22 @@ import { useNotesStore } from '@/stores/notes'
import { useWeatherStore } from '@/stores/weather'
import {
useNotificationsStore,
+ type PhoneNotification,
type PhoneNotificationInput,
} from '@/stores/notifications'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import type { PhoneNotificationDevicePayload } from '@/types/device'
import type { MailCounts } from '@/types/mail'
import type { MarketplaceCounts } from '@/types/marketplace'
+import type {
+ CompanyChangedPayload,
+ CompanyUnreadCounts,
+} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
+import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
@@ -64,6 +72,7 @@ type AppMessage = {
| CalendarReminderData
| MailEventData
| MarketplaceEventData
+ | CompaniesEventData
| MessagesEventData
| DarkChatEventData
| FlareEventData
@@ -76,6 +85,18 @@ type AppMessage = {
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
+ | CustomAppCatalogEventData
+ | CustomAppEventData
+}
+
+type CustomAppCatalogEventData = {
+ apps?: unknown
+}
+
+type CustomAppEventData = {
+ appId?: unknown
+ data?: unknown
+ payload?: unknown
}
type SimPickerPayload = {
@@ -104,6 +125,18 @@ type MessagesEventData = {
title?: string
}
+type CompaniesEventData = {
+ area?: CompanyChangedPayload['area']
+ companyId?: string
+ counts?: CompanyUnreadCounts
+ device?: PhoneNotificationDevicePayload
+ kind?: 'assigned' | 'newMessage' | 'newRequest' | 'requestUpdated'
+ requestId?: string
+ subtitle?: string
+ text?: string
+ title?: string
+}
+
type DarkChatEventData = {
conversationId?: string
device?: PhoneNotificationDevicePayload
@@ -209,6 +242,7 @@ const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const billing = useBillingStore()
+const companies = useCompaniesStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
@@ -218,6 +252,7 @@ const picstagram = usePicstagramStore()
const feather = useFeatherStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
+const appCatalog = useAppCatalogStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
const notes = useNotesStore()
@@ -267,6 +302,8 @@ const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
let clockTicker: ReturnType | undefined
+let companiesChangeTimer: number | undefined
+let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
@@ -279,7 +316,16 @@ function getViewportScale(): number {
}
function hydratePhone(payload: PhoneOpenPayload): void {
+ if (payload.device?.imei) {
+ companies.bindDeviceScope(
+ payload.device.imei,
+ payload.device.sim?.id ?? null,
+ )
+ }
phone.open(payload)
+ phone.ensureAppNotificationPreferences(
+ appCatalog.externalApps.map((app) => app.id),
+ )
if (payload.device?.imei) {
notifications.hydrate(
payload.device.data.notifications?.payload,
@@ -296,6 +342,31 @@ function hydratePhone(payload: PhoneOpenPayload): void {
widgets.hydrate(payload.device?.data.widgets?.payload)
}
+function queueCompaniesChange(change: CompanyChangedPayload): void {
+ if (!pendingCompaniesChange) {
+ pendingCompaniesChange = { ...change }
+ } else {
+ pendingCompaniesChange = {
+ area: pendingCompaniesChange.area === change.area ? change.area : 'all',
+ companyId:
+ pendingCompaniesChange.companyId === change.companyId
+ ? change.companyId
+ : undefined,
+ requestId:
+ pendingCompaniesChange.requestId === change.requestId
+ ? change.requestId
+ : undefined,
+ }
+ }
+ if (companiesChangeTimer !== undefined) return
+ companiesChangeTimer = window.setTimeout(() => {
+ companiesChangeTimer = undefined
+ const queuedChange = pendingCompaniesChange
+ pendingCompaniesChange = null
+ if (queuedChange) void companies.applyChanged(queuedChange)
+ }, 2000)
+}
+
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
@@ -315,6 +386,7 @@ function loadUnlockedPhoneData(): void {
void calls.bootstrap()
void messages.loadConversations()
void billing.loadOverview()
+ void companies.refreshUnreadCounts()
if (account.email) void darkchat.bootstrap()
})
}
@@ -345,7 +417,35 @@ async function hydrateDevelopmentPhone(): Promise {
}
function onMessage(event: MessageEvent): void {
- if (event.data?.type === 'app:open') {
+ if (!isTrustedRootMessageSource(event.source, window)) return
+
+ if (event.data?.type === 'custom-apps:catalog') {
+ appCatalog.replaceCatalog(event.data.data)
+ const activeAppId = route.params.appId
+ if (typeof activeAppId === 'string' && !isPhoneAppId(activeAppId)) {
+ void router.push('/')
+ }
+ } else if (event.data?.type === 'custom-app:message') {
+ const data = event.data.data as CustomAppEventData | undefined
+ if (typeof data?.appId === 'string') {
+ appCatalog.queueHostMessage(data.appId, data.payload)
+ } else {
+ console.error('[Custom apps] Ignored a message without a valid app id.')
+ }
+ } else if (event.data?.type === 'custom-app:open') {
+ const data = event.data.data as CustomAppEventData | undefined
+ if (
+ typeof data?.appId === 'string' &&
+ appCatalog.requestOpen(data.appId, data.data)
+ ) {
+ void router.push(`/apps/${data.appId}`)
+ }
+ } else if (event.data?.type === 'custom-app:close') {
+ const data = event.data.data as CustomAppEventData | undefined
+ if (typeof data?.appId === 'string' && route.params.appId === data.appId) {
+ void router.push('/')
+ }
+ } else if (event.data?.type === 'app:open') {
hydratePhone(event.data.data as PhoneOpenPayload)
void nuiCall('ui:opened')
} else if (event.data?.type === 'device:updated') {
@@ -361,10 +461,7 @@ function onMessage(event: MessageEvent): void {
const data = event.data.data as NotificationEventData
const { device, ...input } = data
const notification: PhoneNotificationInput = input
- if (
- device &&
- (!phone.isOpen || device.imei !== phone.device?.imei)
- ) {
+ if (device && (!phone.isOpen || device.imei !== phone.device?.imei)) {
notification.device = {
imei: device.imei,
name: device.name,
@@ -406,6 +503,47 @@ function onMessage(event: MessageEvent): void {
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
const data = event.data.data as MarketplaceEventData
if (data.counts) marketplace.setCounts(data.counts)
+ } else if (event.data?.type === 'companies:changed') {
+ const data = event.data.data as CompaniesEventData | undefined
+ if (data?.counts) companies.applyUnreadCounts(data.counts)
+ if (data?.area) {
+ queueCompaniesChange({
+ area: data.area,
+ companyId: data.companyId,
+ requestId: data.requestId,
+ })
+ } else if (!data?.counts) queueCompaniesChange({ area: 'all' })
+ } else if (event.data?.type === 'companies:notification' && event.data.data) {
+ const data = event.data.data as CompaniesEventData
+ if (data.counts) companies.applyUnreadCounts(data.counts)
+
+ const notification: PhoneNotificationInput = {
+ appId: 'companies',
+ ...(data.requestId && data.area
+ ? {
+ route: `/apps/companies?requestId=${encodeURIComponent(data.requestId)}&area=${encodeURIComponent(data.area)}`,
+ }
+ : {}),
+ persistent: !phone.isOpen && Boolean(data.requestId && data.area),
+ subtitle: data.subtitle,
+ text:
+ data.text ??
+ phone.t(
+ `Apps.companies.notifications.${data.kind ?? 'requestUpdated'}`,
+ ),
+ title: data.title ?? phone.t('Apps.companies.name'),
+ }
+ if (
+ data.device &&
+ (!phone.isOpen || data.device.imei !== phone.device?.imei)
+ ) {
+ notification.device = {
+ imei: data.device.imei,
+ name: data.device.name,
+ preferences: parsePhonePreferences(data.device.settings ?? null),
+ }
+ }
+ notifications.show(notification)
} else if (
event.data?.type === 'fliptok:verification-changed' &&
event.data.data
@@ -667,10 +805,7 @@ function onMessage(event: MessageEvent): void {
}
}
notifications.show(notification)
- } else if (
- event.data?.type === 'crewlink:notification' &&
- event.data.data
- ) {
+ } else if (event.data?.type === 'crewlink:notification' && event.data.data) {
const data = event.data.data as CrewLinkNotificationData
const notification: PhoneNotificationInput = {
appId: 'crewlink',
@@ -756,6 +891,28 @@ function unlockPhone(): void {
finishUnlock()
}
+function openLockScreenNotification(notification: PhoneNotification): void {
+ if (!notification.route) return
+ pendingUnlockRoute.value = notification.route
+ notifications.dismissFromLockScreen(notification.id)
+ unlockPhone()
+}
+
+async function openNotificationPreview(
+ notification: PhoneNotification,
+): Promise {
+ if (!notification.route) return
+ const imei = notification.device?.imei ?? phone.device?.imei
+ if (!imei) return
+ pendingUnlockRoute.value = notification.route
+ const response = await nuiCall('device:notification-open', { imei })
+ if (!response.success) {
+ pendingUnlockRoute.value = null
+ return
+ }
+ notifications.dismissFromLockScreen(notification.id, imei)
+}
+
function cancelPasscode(): void {
if (passcodeBusy.value) return
passcodeVisible.value = false
@@ -907,6 +1064,7 @@ watch(
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
+ appStore.cancelPendingInstalls()
if (unlockedServicesFrame !== undefined) {
window.cancelAnimationFrame(unlockedServicesFrame)
unlockedServicesFrame = undefined
@@ -961,6 +1119,9 @@ onBeforeUnmount(() => {
}
weather.stop()
if (clockTicker) clearInterval(clockTicker)
+ if (companiesChangeTimer !== undefined) {
+ window.clearTimeout(companiesChangeTimer)
+ }
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
window.removeEventListener('message', onMessage)
@@ -1007,6 +1168,7 @@ onBeforeUnmount(() => {
100)
"
@close="notifications.dismiss(notification.id)"
+ @open="openNotificationPreview"
/>
{
@camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="notifications.dismissFromLockScreen"
+ @open-notification="openLockScreenNotification"
@unlock="unlockPhone"
/>
@@ -1096,6 +1259,7 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/assets/img/app-icons/companies.svg b/frontend/src/assets/img/app-icons/companies.svg
new file mode 100644
index 0000000..b52f7ce
--- /dev/null
+++ b/frontend/src/assets/img/app-icons/companies.svg
@@ -0,0 +1,29 @@
+
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 6006bb2..fb69afa 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -679,6 +679,16 @@ button {
background-color: rgb(28 28 32 / 72%);
text-shadow: 0 1px 3px #0009;
}
+.phone-notification.is-actionable {
+ cursor: pointer;
+}
+.lock-screen__notification.is-actionable {
+ cursor: pointer;
+}
+.lock-screen__notification.is-actionable:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+}
.lock-screen__notification-icon {
width: 42px;
height: 42px;
diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue
index c56d222..17fb331 100644
--- a/frontend/src/components/AppIcon.vue
+++ b/frontend/src/components/AppIcon.vue
@@ -1,12 +1,13 @@
+
+
+
+
+
+
+
+ {{ phone.t('Apps.customApps.loading') }}
+
+
+
+
+ {{ phone.t('Apps.customApps.unavailableTitle') }}
+ {{ phone.t('Apps.customApps.unavailableBody') }}
+
+
+ {{ phone.t('Apps.customApps.close') }}
+
+
+
+
+
+
diff --git a/frontend/src/components/NotificationPhonePreview.vue b/frontend/src/components/NotificationPhonePreview.vue
index d5e8e1a..3d253fc 100644
--- a/frontend/src/components/NotificationPhonePreview.vue
+++ b/frontend/src/components/NotificationPhonePreview.vue
@@ -14,6 +14,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
close: []
+ open: [notification: PhoneNotification]
}>()
const phone = usePhoneStore()
const device = computed(() => props.notification.device!)
@@ -69,6 +70,7 @@ const wrapperStyle = computed
(() => ({ zoom: props.zoom }))
diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue
index 8dd459e..cf547c1 100644
--- a/frontend/src/components/PayphoneOverlay.vue
+++ b/frontend/src/components/PayphoneOverlay.vue
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
+import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState =
| 'idle'
@@ -214,6 +215,7 @@ async function close(): Promise {
}
function onMessage(event: MessageEvent): void {
+ if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'payphone:open' && event.data.data) {
const payload = event.data.data as PayphoneOpenPayload
currency.value = payload.currency
diff --git a/frontend/src/components/PhoneLockScreen.vue b/frontend/src/components/PhoneLockScreen.vue
index fe3b714..f7e7a78 100644
--- a/frontend/src/components/PhoneLockScreen.vue
+++ b/frontend/src/components/PhoneLockScreen.vue
@@ -19,6 +19,7 @@ const emit = defineEmits<{
camera: []
clearNotifications: []
dismissNotification: [id: string]
+ openNotification: [notification: PhoneNotification]
unlock: []
}>()
@@ -72,9 +73,7 @@ const flashlightShortcutColors = computed(() =>
function onPointerDown(event: PointerEvent): void {
if (props.preview) return
if (
- (event.target as HTMLElement).closest(
- 'button, .lock-screen__notifications',
- )
+ (event.target as HTMLElement).closest('button, .lock-screen__notifications')
)
return
pointerStart = event.clientY
@@ -83,6 +82,10 @@ function onPointerDown(event: PointerEvent): void {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
+function openNotification(notification: PhoneNotification): void {
+ if (notification.route) emit('openNotification', notification)
+}
+
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = Math.min(0, event.clientY - pointerStart)
@@ -194,7 +197,16 @@ onBeforeUnmount(() => {
v-for="notification in props.notifications"
:key="notification.id"
class="lock-screen__notification"
+ :class="{ 'is-actionable': !!notification.route }"
:highlight="false"
+ :role="notification.route ? 'button' : undefined"
+ :tabindex="notification.route ? 0 : undefined"
+ :aria-label="
+ notification.route ? phone.t('Notifications.open') : undefined
+ "
+ @click="openNotification(notification)"
+ @keydown.enter.prevent="openNotification(notification)"
+ @keydown.space.prevent="openNotification(notification)"
>
{
{{ notification.title }}
{{ phone.t('Notifications.now') }}
- {{ notification.subtitle }}
+ {{
+ notification.subtitle
+ }}
{{ notification.text }}
+
{
await runSearch()
}
-async function shareProfile(): Promise {
- if (!activeProfile.value) return
- try {
- await navigator.clipboard.writeText(`@${activeProfile.value.handle}`)
- toast('profileCopied')
- } catch (error) {
- console.error('[Feather] Could not copy the profile handle.', error)
- toast('errors.generic')
- }
+function shareProfile(): void {
+ const profile = activeProfile.value
+ if (!profile) return
+ useEasyShareStore().open({
+ appId: 'feather',
+ copyText: `@${profile.handle}`,
+ id: profile.id,
+ imageUrl: profile.avatar_url,
+ kind: 'profile',
+ link: `skyphone://feather/profile/${profile.id}`,
+ subtitle: `@${profile.handle}`,
+ title: profile.display_name,
+ })
}
-async function sharePost(post: FeatherPost): Promise {
- try {
- await navigator.clipboard.writeText(`@${post.handle}: ${post.body}`)
- toast('postCopied')
- } catch (error) {
- console.error('[Feather] Could not copy the post.', error)
- toast('errors.generic')
- }
+function sharePost(post: FeatherPost): void {
+ useEasyShareStore().open({
+ appId: 'feather',
+ copyText: `@${post.handle}: ${post.body}`,
+ id: post.id,
+ imageUrl: post.media[0]?.url,
+ kind: 'post',
+ link: `skyphone://feather/post/${post.id}`,
+ subtitle: `@${post.handle}`,
+ title: post.body,
+ })
}
function openComposer(post?: FeatherPost): void {
diff --git a/frontend/src/views/apps/FlareApp.vue b/frontend/src/views/apps/FlareApp.vue
index 473f0fb..fc96f4c 100644
--- a/frontend/src/views/apps/FlareApp.vue
+++ b/frontend/src/views/apps/FlareApp.vue
@@ -45,6 +45,7 @@ import {
RotateCcw,
Search,
Settings2,
+ Share2,
Star,
SlidersHorizontal,
UserRound,
@@ -59,12 +60,14 @@ import {
onMounted,
reactive,
ref,
+ watch,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import profilesSprite from '@/assets/img/flare/profiles-source.png'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
+import { useEasyShareStore } from '@/stores/easyshare'
import { useFlareStore } from '@/stores/flare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useMessagesStore } from '@/stores/messages'
@@ -97,6 +100,7 @@ type FlareMediaContext = {
type FlareChatMediaContext = { matchId: string }
const phone = usePhoneStore()
+const easyShare = useEasyShareStore()
const flare = useFlareStore()
const messageMedia = useMessageMediaStore()
const messages = useMessagesStore()
@@ -128,6 +132,21 @@ const gifResults = ref([])
const gifLoading = ref(false)
const gifError = ref(null)
const gifHasMore = ref(true)
+
+function shareProfile(): void {
+ const profile = flare.profile
+ if (!profile) return
+ easyShare.open({
+ appId: 'flare',
+ copyText: `${profile.name}, ${profile.age}\n${profile.bio}`,
+ id: profile.id,
+ imageUrl: profile.photoUrls[0],
+ kind: 'profile',
+ link: `skyphone://flare/profile/${profile.id}`,
+ subtitle: phone.t(`Apps.flare.lookingFor.${profile.lookingFor}`),
+ title: `${profile.name}, ${profile.age}`,
+ })
+}
const gifNextOffset = ref(0)
const draftPhotos = ref([])
const activeChoiceField = ref('gender')
@@ -568,6 +587,19 @@ async function openMatch(match: FlareMatch): Promise {
messageScroll.value?.scrollTo({ top: messageScroll.value.scrollHeight })
}
+async function openEasyShareDraft(): Promise {
+ const shared = easyShare.consumeChatDraft('flare')
+ if (!shared?.targetId) return false
+ const match = flare.matches.find((item) => item.id === shared.targetId)
+ if (!match) {
+ showActionError()
+ return true
+ }
+ await openMatch(match)
+ if (activeMatch.value?.id === match.id) draft.value = shared.body
+ return true
+}
+
function closeMatch(): void {
activeMatch.value = null
draft.value = ''
@@ -739,6 +771,7 @@ function messageTime(value: DatabaseDateValue): string {
onMounted(async () => {
await flare.bootstrap()
+ await openEasyShareDraft()
const selection = messageMedia.consumeMany(
'flare:profile-photos',
)
@@ -790,6 +823,13 @@ onMounted(async () => {
}
})
+watch(
+ () => easyShare.chatDraft,
+ (shared) => {
+ if (shared?.appId === 'flare') void openEasyShareDraft()
+ },
+)
+
onBeforeUnmount(() => {
if (gifSearchTimer) clearTimeout(gifSearchTimer)
})
@@ -1539,6 +1579,10 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.flare.editProfile') }}
+
diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue
index a0b8434..a6ad07e 100644
--- a/frontend/src/views/apps/FlipTokApp.vue
+++ b/frontend/src/views/apps/FlipTokApp.vue
@@ -54,6 +54,7 @@ import { useRouter } from 'vue-router'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import { useFlipTokStore } from '@/stores/fliptok'
+import { useEasyShareStore } from '@/stores/easyshare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type {
@@ -604,8 +605,30 @@ function openActions(video: FlipTokVideo): void {
async function shareVideo(video: FlipTokVideo): Promise {
const response = await nuiCall('fliptok:share', { id: video.id })
if (response.success) video.share_count += 1
- await navigator.clipboard?.writeText(`fliptok://video/${video.id}`)
- notify(t('linkCopied'))
+ useEasyShareStore().open({
+ appId: 'fliptok',
+ copyText: `@${video.handle}: ${video.caption}`,
+ id: video.id,
+ imageUrl: video.url,
+ kind: 'post',
+ link: `skyphone://fliptok/video/${video.id}`,
+ subtitle: `@${video.handle}`,
+ title: video.caption || video.display_name,
+ })
+}
+
+function shareCurrentProfile(): void {
+ const profile = currentProfile.value
+ if (!profile) return
+ useEasyShareStore().open({
+ appId: 'fliptok',
+ copyText: `@${profile.handle}`,
+ id: profile.id,
+ kind: 'profile',
+ link: `skyphone://fliptok/profile/${profile.id}`,
+ subtitle: `@${profile.handle}`,
+ title: profile.display_name,
+ })
}
async function reportVideo(): Promise {
@@ -1160,6 +1183,10 @@ onBeforeUnmount(() => {
>{{ t('block') }}
+
+
+ {{ phone.t('Apps.easyShare.shareProfile') }}
+
+
+
+
@@ -984,7 +1018,11 @@ onBeforeUnmount(() => {
-
@@ -1553,15 +1441,19 @@ onBeforeUnmount(() => {
v-else-if="screen === 'company'"
class="companies-content company-profile"
>
-
{{ phone.t('Apps.companies.loading.profile') }}
-
-
+
@@ -1570,7 +1462,7 @@ onBeforeUnmount(() => {
{{
phone.t('Apps.companies.back')
}}
-
+
{
/>
-
+
{
phone.t('Apps.companies.profile.request')
}}
-
+
@@ -1734,12 +1626,19 @@ onBeforeUnmount(() => {
v-else-if="screen === 'request'"
class="companies-content request-thread"
>
-
+
{{ phone.t('Apps.companies.loading.request') }}
-
-
+
@@ -1748,7 +1647,7 @@ onBeforeUnmount(() => {
{{
phone.t('Apps.companies.back')
}}
-
+
{{ phone.t('Apps.localPages.cityMarktShare') }}
@@ -1131,7 +1142,8 @@ onMounted(async () => {
"
class="citymarkt__pages-share"
type="button"
- @click="shareToLocalPages"
+ :disabled="pagesSharePendingId !== null"
+ @click="shareToLocalPages()"
>
{{ phone.t('Apps.localPages.cityMarktShare') }} {
min-height: 76px;
padding: 8px !important;
}
+.citymarkt-profile-listing > .citymarkt-profile-listing__share {
+ min-height: 36px;
+ padding: 8px 10px !important;
+ border-top: 1px solid #ffc92826;
+ justify-content: center;
+ color: var(--yellow);
+ font-size: 11px;
+ font-weight: 800;
+}
+.citymarkt-profile-listing__share:disabled { opacity: .45 }
.citymarkt-profile-listing > button > span {
width: 60px !important;
height: 60px !important;
From 4255a90d2edf4c571bf5a378210fc916478cbda2 Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:25:36 +0200
Subject: [PATCH 25/63] FIX - suggest compatible EasyShare destinations
---
frontend/src/components/EasyShareSheet.vue | 23 ++++++------
frontend/src/types/easyshare.ts | 1 +
frontend/src/utils/easyshare.test.ts | 43 +++++++++++++++++++++-
frontend/src/utils/easyshare.ts | 29 ++++++++++++++-
4 files changed, 83 insertions(+), 13 deletions(-)
diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue
index f316806..1d24ad2 100644
--- a/frontend/src/components/EasyShareSheet.vue
+++ b/frontend/src/components/EasyShareSheet.vue
@@ -24,10 +24,14 @@ import { useNotesStore } from '@/stores/notes'
import { usePhoneStore } from '@/stores/phone'
import type {
EasyShareChatApp,
+ EasyShareDestinationApp,
EasyShareTransfer,
EasyShareVisibility,
} from '@/types/easyshare'
-import { openEasySharePayload } from '@/utils/easyshare'
+import {
+ easyShareDestinationAppIds,
+ openEasySharePayload,
+} from '@/utils/easyshare'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
@@ -45,7 +49,6 @@ let dragPointerId: number | null = null
let dragStartTime = 0
let dragStartY = 0
const visibilityOptions: EasyShareVisibility[] = ['everyone', 'contacts', 'hidden']
-type EasyShareDestinationAppId = 'darkchat' | 'flare' | 'messages' | 'notes'
const app = computed(() =>
easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined,
)
@@ -105,14 +108,8 @@ const sharePeople = computed(() => {
return people
})
-const shareAppIds: EasyShareDestinationAppId[] = [
- 'messages',
- 'darkchat',
- 'flare',
- 'notes',
-]
const shareApps = computed(() =>
- shareAppIds
+ (easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
.filter((id) => !appStore.homeLayout.hidden.includes(id))
.flatMap((id) => {
const app = getPhoneApp(id)
@@ -201,7 +198,7 @@ function openChatApp(kind: EasyShareChatApp): void {
void router.push(`/apps/${kind}`)
}
-function openShareApp(appId: EasyShareDestinationAppId): void {
+function openShareApp(appId: EasyShareDestinationApp): void {
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
@@ -211,8 +208,12 @@ function openShareApp(appId: EasyShareDestinationAppId): void {
function saveAsNote(): void {
if (!easyShare.payload) return
+ const body = [easyShare.payload.copyText.trim(), easyShare.payload.link?.trim()]
+ .filter((part): part is string => Boolean(part))
+ .filter((part, index, parts) => parts.indexOf(part) === index)
+ .join('\n')
notes.createNote({
- body: easyShare.payload.copyText,
+ body,
title: easyShare.payload.title,
})
feedback.value = label('savedToNotes')
diff --git a/frontend/src/types/easyshare.ts b/frontend/src/types/easyshare.ts
index 368883c..8b9665d 100644
--- a/frontend/src/types/easyshare.ts
+++ b/frontend/src/types/easyshare.ts
@@ -16,6 +16,7 @@ export type EasyShareKind =
export type EasyShareVisibility = 'contacts' | 'everyone' | 'hidden'
export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages'
+export type EasyShareDestinationApp = EasyShareChatApp | 'notes'
export type EasyShareChatDraft = {
appId: EasyShareChatApp
body: string
diff --git a/frontend/src/utils/easyshare.test.ts b/frontend/src/utils/easyshare.test.ts
index f3fc67c..37fc749 100644
--- a/frontend/src/utils/easyshare.test.ts
+++ b/frontend/src/utils/easyshare.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { EasySharePayload } from '@/types/easyshare'
-import { easyShareRoute } from '@/utils/easyshare'
+import {
+ easyShareDestinationAppIds,
+ easyShareRoute,
+} from '@/utils/easyshare'
function payload(overrides: Partial): EasySharePayload {
return {
@@ -72,3 +75,41 @@ describe('EasyShare deep links', () => {
})
})
})
+
+describe('EasyShare destination suggestions', () => {
+ it.each([
+ 'document',
+ 'link',
+ 'location',
+ 'note',
+ 'text',
+ ] as const)('offers Notes for %s content from another app', (kind) => {
+ expect(
+ easyShareDestinationAppIds(payload({ appId: 'calendar', kind })),
+ ).toEqual(['messages', 'darkchat', 'flare', 'notes'])
+ })
+
+ it.each([
+ 'contact',
+ 'photo',
+ 'playlist',
+ 'post',
+ 'profile',
+ 'track',
+ 'video',
+ ] as const)('does not suggest lossy Notes conversion for %s content', (kind) => {
+ expect(easyShareDestinationAppIds(payload({ kind }))).toEqual([
+ 'messages',
+ 'darkchat',
+ 'flare',
+ ])
+ })
+
+ it('does not suggest saving a Notes item back into Notes', () => {
+ expect(easyShareDestinationAppIds(payload({}))).toEqual([
+ 'messages',
+ 'darkchat',
+ 'flare',
+ ])
+ })
+})
diff --git a/frontend/src/utils/easyshare.ts b/frontend/src/utils/easyshare.ts
index 44f4fad..059572a 100644
--- a/frontend/src/utils/easyshare.ts
+++ b/frontend/src/utils/easyshare.ts
@@ -1,7 +1,24 @@
import type { Router } from 'vue-router'
import { getPhoneApp } from '@/config/apps'
-import type { EasySharePayload } from '@/types/easyshare'
+import type {
+ EasyShareDestinationApp,
+ EasyShareKind,
+ EasySharePayload,
+} from '@/types/easyshare'
+
+const chatDestinations: EasyShareDestinationApp[] = [
+ 'messages',
+ 'darkchat',
+ 'flare',
+]
+const noteCompatibleKinds = new Set([
+ 'document',
+ 'link',
+ 'location',
+ 'note',
+ 'text',
+])
const schemeRoutes: Record = {
location: '/apps/map',
@@ -9,6 +26,16 @@ const schemeRoutes: Record = {
phone: '/apps/phone',
}
+export function easyShareDestinationAppIds(
+ payload: EasySharePayload,
+): EasyShareDestinationApp[] {
+ const destinations = [...chatDestinations]
+ if (payload.appId !== 'notes' && noteCompatibleKinds.has(payload.kind)) {
+ destinations.push('notes')
+ }
+ return destinations
+}
+
export function easyShareRoute(payload: EasySharePayload): {
path: string
query: Record
From 7c697fd9931f528248b2fe617373ed806a0d6099 Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:40:58 +0200
Subject: [PATCH 26/63] ADD - share CrewLink group invitations
---
frontend/src/stores/phone.ts | 3 ++
frontend/src/utils/easyshare.test.ts | 21 +++++++++++++
frontend/src/utils/easyshare.ts | 16 ++++++++++
frontend/src/views/apps/CrewLinkApp.vue | 40 ++++++++++++++++++++++--
sky_phone/config/locales/en.lua | 2 +-
sky_phone/source/server/easyshare.lua | 41 +++++++++++++++++++++++--
6 files changed, 118 insertions(+), 5 deletions(-)
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index a0524ec..66b7e9c 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -475,6 +475,9 @@ const defaultLocales: LocaleTree = {
private: 'Private',
nearby: 'Nearby',
copyCode: 'Copy code',
+ shareInvite: 'Invite by message',
+ shareInviteTitle: 'Join {group} on CrewLink',
+ shareInviteBody: 'Use invitation code {code} to join {group}.',
manage: 'Manage',
refresh: 'Refresh',
codeCopied: 'Invitation code copied.',
diff --git a/frontend/src/utils/easyshare.test.ts b/frontend/src/utils/easyshare.test.ts
index 37fc749..1960381 100644
--- a/frontend/src/utils/easyshare.test.ts
+++ b/frontend/src/utils/easyshare.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { EasySharePayload } from '@/types/easyshare'
import {
+ easyShareCrewLinkInviteCode,
easyShareDestinationAppIds,
easyShareRoute,
} from '@/utils/easyshare'
@@ -74,6 +75,26 @@ describe('EasyShare deep links', () => {
query: { easyShareId: id, easyShareKind: kind },
})
})
+
+ it('extracts a CrewLink invitation code from a shared deep link', () => {
+ expect(
+ easyShareCrewLinkInviteCode(
+ 'link',
+ 'skyphone://crewlink/invite/ab12cd34',
+ 'ignored',
+ ),
+ ).toBe('AB12CD34')
+ })
+
+ it('accepts the canonical payload id as a CrewLink invite fallback', () => {
+ expect(easyShareCrewLinkInviteCode('link', '', 'n1ght247')).toBe('N1GHT247')
+ expect(
+ easyShareCrewLinkInviteCode('profile', '', 'n1ght247'),
+ ).toBeNull()
+ expect(
+ easyShareCrewLinkInviteCode('link', '', 'invalid-code'),
+ ).toBeNull()
+ })
})
describe('EasyShare destination suggestions', () => {
diff --git a/frontend/src/utils/easyshare.ts b/frontend/src/utils/easyshare.ts
index 059572a..c21ea79 100644
--- a/frontend/src/utils/easyshare.ts
+++ b/frontend/src/utils/easyshare.ts
@@ -59,6 +59,22 @@ export function easyShareRoute(payload: EasySharePayload): {
}
}
+export function easyShareCrewLinkInviteCode(
+ kind: unknown,
+ link: unknown,
+ id: unknown,
+): string | null {
+ if (kind !== 'link') return null
+ if (typeof link === 'string') {
+ const match = link.match(/^skyphone:\/\/crewlink\/invite\/([a-z0-9]{8})$/i)
+ if (match) return match[1].toUpperCase()
+ }
+ if (typeof id === 'string' && /^[a-z0-9]{8}$/i.test(id)) {
+ return id.toUpperCase()
+ }
+ return null
+}
+
export async function openEasySharePayload(
router: Router,
payload: EasySharePayload,
diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue
index c0d0fdf..9680cd4 100644
--- a/frontend/src/views/apps/CrewLinkApp.vue
+++ b/frontend/src/views/apps/CrewLinkApp.vue
@@ -59,7 +59,7 @@ import {
onMounted,
ref,
} from 'vue'
-import { useRouter } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
import {
defaultCayoStyle,
@@ -81,6 +81,7 @@ import type {
CrewLinkRole,
} from '@/types/crewlink'
import { copyText } from '@/utils/clipboard'
+import { easyShareCrewLinkInviteCode } from '@/utils/easyshare'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
@@ -96,6 +97,7 @@ type CrewLinkSheet =
const phone = usePhoneStore()
const crew = useCrewLinkStore()
+const route = useRoute()
const router = useRouter()
const mainlandMapUrl = `${import.meta.env.BASE_URL}img/maps/gtav-map.svg`
const cayoMapUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
@@ -105,6 +107,13 @@ const username = ref('')
const groupName = ref('')
const groupColour = ref('cyan')
const inviteCode = ref('')
+const sharedInviteCode = ref(
+ easyShareCrewLinkInviteCode(
+ route.query.easyShareKind,
+ route.query.easyShareLink,
+ route.query.easyShareId,
+ ),
+)
const pingType = ref('meeting')
const pingLabel = ref('')
const pingAtMapCenter = ref(false)
@@ -257,6 +266,30 @@ function shareProfile(): void {
})
}
+function shareGroupInvite(): void {
+ const group = activeGroup.value
+ if (!group?.inviteCode) return
+ useEasyShareStore().open({
+ appId: 'crewlink',
+ copyText: t('shareInviteBody', {
+ code: group.inviteCode,
+ group: group.name,
+ }),
+ id: group.inviteCode,
+ kind: 'link',
+ link: `skyphone://crewlink/invite/${group.inviteCode}`,
+ subtitle: group.inviteCode,
+ title: t('shareInviteTitle', { group: group.name }),
+ })
+}
+
+function openSharedInvite(): void {
+ if (!sharedInviteCode.value || !crew.profile) return
+ openSheet('join-group')
+ inviteCode.value = sharedInviteCode.value
+ sharedInviteCode.value = null
+}
+
function updateValue(
target: 'username' | 'groupName' | 'inviteCode' | 'pingLabel',
event: Event,
@@ -334,6 +367,7 @@ async function createProfile(): Promise {
return
}
showToast(t('profileCreated'))
+ openSharedInvite()
}
async function createGroup(): Promise {
@@ -668,6 +702,7 @@ function onCrewLinkMessage(event: MessageEvent): void {
onMounted(async () => {
await crew.bootstrap()
username.value = crew.profile?.username ?? ''
+ openSharedInvite()
await nextTick()
fitOnlineMembers()
liveTimer = window.setInterval(() => void crew.refreshLive(), 3000)
@@ -854,6 +889,7 @@ onBeforeUnmount(() => {
{{ t('nearby') }}
{{ t('copyCode') }}
+ {{ t('shareInvite') }}
{{ t('manage') }}
@@ -1183,7 +1219,7 @@ onBeforeUnmount(() => {
.crewlink-map-controls{position:absolute;right:10px;top:10px;display:flex;flex-direction:column;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.18)}.crewlink-map-controls button{width:34px;height:34px;border:0;border-bottom:1px solid rgba(0,0,0,.1);background:rgba(255,255,255,.9);color:#183044;font-size:19px;display:grid;place-items:center}.crewlink-map-controls svg{width:16px}.crewlink-map-legend{position:absolute;left:10px;bottom:10px;padding:6px 8px;border-radius:10px;display:flex;gap:9px;background:rgba(8,20,30,.78);backdrop-filter:blur(10px);color:white;font-size:8px}.crewlink-map-legend span{display:flex;align-items:center;gap:4px}.crewlink-map-legend i{width:6px;height:6px;border-radius:50%;background:#35dc80}.crewlink-map-legend i.is-hidden{background:#8998a4}.crewlink-map-crosshair{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:#ff4d67}.crewlink-map-crosshair svg{width:32px;height:32px;filter:drop-shadow(0 2px 4px white)}
.crewlink-map-actions{height:76px;padding:8px 10px;display:grid;grid-template-columns:1fr 1fr;gap:8px;background:#0c1822}.crewlink-map-actions button{border:1px solid rgba(255,255,255,.08);border-radius:15px;display:flex;align-items:center;gap:10px;padding:9px 11px;color:white;background:rgba(255,255,255,.06);text-align:left}.crewlink-map-actions button:disabled{opacity:.4}.crewlink-map-actions svg{width:23px;height:23px;flex:none;color:#28d9e8}.crewlink-map-actions span{display:flex;min-width:0;flex-direction:column;gap:2px}.crewlink-map-actions strong{font-size:15px;line-height:18px;white-space:nowrap}.crewlink-map-actions small{font-size:12px;line-height:15px;color:#a4b4c0;white-space:nowrap}
.crewlink-group-hero{padding:22px;border-radius:25px;color:white;background:radial-gradient(circle at 82% 18%,var(--crew-aura),transparent 35%),linear-gradient(145deg,#0b2433,#0b1825);box-shadow:0 15px 35px rgba(6,20,31,.2)}.crewlink-group-hero__signal{width:48px;height:48px;border-radius:16px;display:grid;place-items:center;background:var(--crew);box-shadow:0 0 28px var(--crew-glow)}.crewlink-group-hero__signal svg{width:26px}.crewlink-group-hero>small{display:block;margin-top:17px;font-size:12px;font-weight:600;line-height:16px;letter-spacing:.12em;text-transform:uppercase;color:#b5c6d0}.crewlink-group-hero h1{margin:5px 0;font-size:29px;line-height:34px}.crewlink-group-hero p{margin:0 0 14px;font-size:14px;line-height:18px;color:#d0dbe1}.crewlink-group-hero>div:last-child{display:flex;gap:7px}.crewlink-group-hero :deep(.crewlink-group-badge){min-height:24px;padding:4px 8px;font-size:13px}
-.crewlink-quick-actions{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:13px 0 11px}.crewlink-quick-actions button{min-height:64px;border:0;border-radius:15px;padding:11px 5px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;color:var(--cl-text);background:var(--cl-surface);font-size:12px;font-weight:600;line-height:15px;box-shadow:0 3px 12px rgba(15,40,60,.06)}.crewlink-quick-actions svg{width:22px;height:22px;color:#138fc0}.crewlink-invitations--inline{margin:0}.crewlink-members-title{height:auto!important;min-height:34px;padding-top:8px!important;padding-bottom:6px!important;font-size:16px!important;font-weight:700!important;color:var(--cl-text)!important}.crewlink-member-list :deep(.k-list-item){min-height:70px}.crewlink-member-title{font-size:18px;font-weight:700;line-height:22px}.crewlink-member-subtitle{font-size:14px;line-height:18px;color:var(--cl-muted)}.crewlink-avatar{position:relative;width:43px;height:43px;border-radius:15px;display:grid;place-items:center;color:white;background:linear-gradient(145deg,var(--crew),#273e55);font-size:12px;font-weight:900}.crewlink-avatar i{position:absolute;right:-2px;bottom:-2px;width:11px;height:11px;border:2px solid white;border-radius:50%;background:#8998a4}.crewlink-avatar i.is-online{background:#35d880}.role-owner{color:#ffb020}.role-coordinator{color:#8b5cf6}.role-moderator{color:#2d9cff}.role-member{color:#22b77a}.role-guest{color:#8998a4}
+.crewlink-quick-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:13px 0 11px}.crewlink-quick-actions button{min-height:64px;border:0;border-radius:15px;padding:11px 5px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;color:var(--cl-text);background:var(--cl-surface);font-size:12px;font-weight:600;line-height:15px;box-shadow:0 3px 12px rgba(15,40,60,.06)}.crewlink-quick-actions svg{width:22px;height:22px;color:#138fc0}.crewlink-invitations--inline{margin:0}.crewlink-members-title{height:auto!important;min-height:34px;padding-top:8px!important;padding-bottom:6px!important;font-size:16px!important;font-weight:700!important;color:var(--cl-text)!important}.crewlink-member-list :deep(.k-list-item){min-height:70px}.crewlink-member-title{font-size:18px;font-weight:700;line-height:22px}.crewlink-member-subtitle{font-size:14px;line-height:18px;color:var(--cl-muted)}.crewlink-avatar{position:relative;width:43px;height:43px;border-radius:15px;display:grid;place-items:center;color:white;background:linear-gradient(145deg,var(--crew),#273e55);font-size:12px;font-weight:900}.crewlink-avatar i{position:absolute;right:-2px;bottom:-2px;width:11px;height:11px;border:2px solid white;border-radius:50%;background:#8998a4}.crewlink-avatar i.is-online{background:#35d880}.role-owner{color:#ffb020}.role-coordinator{color:#8b5cf6}.role-moderator{color:#2d9cff}.role-member{color:#22b77a}.role-guest{color:#8998a4}
.crewlink-section-header{display:flex;align-items:center;gap:13px;margin:5px 2px 16px}.crewlink-section-header>span{width:52px;height:52px;border-radius:17px;display:grid;place-items:center;color:white;background:linear-gradient(145deg,#27d9ed,#287cff)}.crewlink-section-header>span svg{width:26px;height:26px}.crewlink-section-header div{flex:1}.crewlink-section-header small{font-size:13px;font-weight:700;line-height:16px;color:#29b8e8;text-transform:uppercase;letter-spacing:.09em}.crewlink-section-header h1{margin:2px 0 0;font-size:29px;line-height:34px}.crewlink-section-header button{width:42px;height:42px;border:0;border-radius:14px;display:grid;place-items:center;color:white;background:#168dc2}.crewlink-section-header button svg{width:22px;height:22px}.crewlink-pings-tab{padding-top:12px}.crewlink-pings-tab .crewlink-section-header{margin-bottom:12px}.crewlink-ping-list{display:flex;flex-direction:column;gap:8px}.crewlink-ping-list :deep(.k-card){margin:0}.crewlink-ping-list article{display:grid;grid-template-columns:47px 1fr 32px 32px;gap:9px;align-items:center;padding:11px}.crewlink-ping-list article>i{width:47px;height:47px;border-radius:16px;display:grid;place-items:center;color:white}.crewlink-ping-list article>i svg{width:23px}.crewlink-ping-list article>div{display:flex;flex-direction:column;gap:2px;min-width:0}.crewlink-ping-list small{font-size:11px;line-height:14px;color:var(--cl-muted);text-transform:uppercase}.crewlink-ping-list strong{font-size:15px;line-height:19px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crewlink-ping-list span{font-size:12px;line-height:15px;color:var(--cl-muted)}.crewlink-ping-list button{border:0;background:transparent;color:#238fbd;display:grid;place-items:center}.crewlink-ping-list button:last-child{color:#ed5268}.crewlink-ping-list button svg{width:20px}.crewlink-empty-state{padding:68px 22px;text-align:center;display:flex;flex-direction:column;align-items:center}.crewlink-empty-state>span{width:70px;height:70px;border-radius:23px;display:grid;place-items:center;color:#168dbd;background:rgba(39,191,230,.12)}.crewlink-empty-state>span svg{width:29px;height:29px}.crewlink-empty-state h2{margin:17px 0 7px;font-size:21px;line-height:26px}.crewlink-empty-state p{max-width:280px;margin:0;color:var(--cl-muted);font-size:14px;line-height:20px}
.crewlink-profile-card{display:flex;align-items:center;gap:13px;padding:17px;border-radius:23px;color:white;background:linear-gradient(145deg,var(--crew),#183a5a)}.crewlink-profile-card>span{width:54px;height:54px;border:3px solid rgba(255,255,255,.72);border-radius:19px;display:grid;place-items:center;font-size:15px;font-weight:900;background:rgba(8,25,40,.22)}.crewlink-profile-card div{min-width:0}.crewlink-profile-card small{font-size:8px;letter-spacing:.1em;text-transform:uppercase;opacity:.75}.crewlink-profile-card h1{margin:2px 0;font-size:19px}.crewlink-profile-card p{margin:0;font-size:10px;opacity:.82}.crewlink-group-dot{width:35px;height:35px;border-radius:13px;display:grid;place-items:center;color:white}.crewlink-group-dot svg{width:18px}.crewlink-danger-row{--k-list-item-title-text-color:#e44760}.crewlink-danger-button{color:#e44760!important}.crewlink-role-title{margin:16px 0 8px!important;padding-inline:4px!important}.crewlink-role-list{margin:0!important}.crewlink-role-list :deep(.crewlink-role-item__content){min-height:68px;align-items:center}.crewlink-role-list :deep(.crewlink-role-item__media){width:28px;margin-right:10px;justify-content:center;color:#9ca8b3}.crewlink-role-list :deep(.crewlink-role-item__inner){min-width:0;padding-top:9px;padding-bottom:9px;text-align:left}.crewlink-role-list :deep(.crewlink-role-item__title){min-height:22px;font-size:14px;line-height:18px}.crewlink-role-list :deep(.crewlink-role-item__title+div){max-width:230px;color:var(--cl-muted);font-size:10px;line-height:14px;text-align:left}.crewlink-role-list :deep(.crewlink-role-item__title svg){color:#aab5bf}.crewlink-member-actions{display:grid;gap:8px;margin-top:12px}.crewlink-member-actions :deep(.k-button){margin-top:0}
.crewlink-sheet__content{position:relative;max-height:82vh;overflow-y:auto;padding:26px 18px 24px;text-align:center;color:var(--cl-text);background:var(--cl-bg);border-radius:24px 24px 0 0}.crewlink-sheet__close{position:absolute;right:14px;top:12px;width:31px;height:31px;border-radius:50%;display:grid;place-items:center;color:var(--cl-muted);background:rgba(125,145,160,.15)}.crewlink-sheet__close svg{width:17px}.crewlink-sheet__icon,.crewlink-sheet__avatar{width:56px;height:56px;margin:0 auto 9px;border-radius:20px;display:grid;place-items:center;color:white;background:linear-gradient(145deg,#27d9ed,#287cff);box-shadow:0 10px 25px rgba(31,139,205,.22)}.crewlink-sheet__avatar{background:linear-gradient(145deg,var(--crew),#29415a);font-weight:900}.crewlink-sheet__icon svg{width:26px}.crewlink-sheet__content h2{margin:4px 0;font-size:23px;line-height:1.2}.crewlink-sheet__content>p{margin:0 10px 13px;color:var(--cl-muted);font-size:13px;line-height:1.45}.crewlink-sheet__content :deep(.button){width:100%;margin-top:9px;font-size:13px}.crewlink-nearby-list{margin:10px 0 0!important}.crewlink-nearby-list :deep(.crewlink-nearby-item__content){min-height:66px;align-items:center}.crewlink-nearby-list :deep(.crewlink-nearby-item__media){width:38px;margin-right:12px;justify-content:center}.crewlink-nearby-list :deep(.crewlink-nearby-item__inner){min-width:0;padding-top:9px;padding-bottom:9px;text-align:left}.crewlink-nearby-list :deep(.crewlink-nearby-item__title){min-height:22px;gap:8px;font-size:14px;line-height:18px}.crewlink-nearby-list :deep(.crewlink-nearby-item__title+div){color:var(--cl-muted);font-size:10px;line-height:14px;text-align:left}.crewlink-nearby-list :deep(.crewlink-nearby-invite){width:auto;margin-top:0;padding-inline:13px;flex:none}.crewlink-field-label{display:block;margin:8px 0;text-align:left;font-size:12px;font-weight:700;color:var(--cl-muted)}.crewlink-colours{display:flex;justify-content:center;gap:9px;margin:8px 0 14px}.crewlink-colours button{width:35px;height:35px;border:3px solid transparent;border-radius:50%;display:grid;place-items:center;color:white}.crewlink-colours button.is-active{border-color:white;box-shadow:0 0 0 2px currentColor}.crewlink-colours svg{width:15px;opacity:0}.crewlink-colours button.is-active svg{opacity:1}.crewlink-error{color:#df3e58!important;font-size:12px!important;margin:8px!important}.crewlink-sheet-empty{padding:25px;display:flex;flex-direction:column;align-items:center;gap:5px;color:var(--cl-muted)}.crewlink-sheet-empty svg{width:34px}.crewlink-sheet-empty strong{color:var(--cl-text)}.crewlink-sheet-empty span{font-size:11px}.crewlink-ping-title{font-size:25px!important;line-height:30px!important}.crewlink-sheet__content>.crewlink-ping-description{font-size:14px;line-height:19px}.crewlink-ping-types{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:15px 0}.crewlink-ping-types button{min-width:0;border:1px solid transparent;border-radius:13px;padding:10px 2px;display:flex;flex-direction:column;align-items:center;gap:6px;color:var(--cl-muted);background:var(--cl-surface);font-size:12px;font-weight:600;line-height:15px}.crewlink-ping-types button.is-active{border-color:var(--ping);color:var(--ping);box-shadow:0 3px 12px var(--ping-glow)}.crewlink-ping-types svg{width:22px;height:22px}.crewlink-ping-form :deep(.k-list-input .text-xs){font-size:14px!important;font-weight:600;line-height:18px}.crewlink-ping-form :deep(.crewlink-ping-label-input){font-size:16px!important;line-height:20px}.crewlink-ping-location-list :deep(.k-list-item){min-height:70px}.crewlink-ping-location-copy{display:flex;flex-direction:column;gap:3px;text-align:left}.crewlink-ping-location-copy strong{font-size:17px;line-height:21px}.crewlink-ping-location-copy small{max-width:205px;color:var(--cl-muted);font-size:14px;line-height:18px}.crewlink-sheet__content :deep(.crewlink-share-ping){font-size:16px}.crewlink-code-card{display:grid;grid-template-columns:1fr auto 30px 30px;align-items:center;gap:5px;text-align:left}.crewlink-code-card small{font-size:9px;color:var(--cl-muted)}.crewlink-code-card strong{font-family:monospace;letter-spacing:.12em}.crewlink-code-card button{border:0;background:transparent;color:#168dbd}.crewlink-code-card svg{width:16px}.crewlink-member-preview{padding-top:34px}.crewlink-tabbar{z-index:20}.crewlink-tabbar :deep(.crewlink-tabbar__inner){width:100%!important;max-width:none!important;padding-inline:4px!important}.crewlink-tabbar :deep(.crewlink-tabbar__pane){width:100%!important;max-width:none!important;gap:2px;padding:0}.crewlink-tabbar :deep(.crewlink-tabbar__pane>.k-link){min-width:0!important;max-width:none!important;flex:1 1 25%!important;padding-inline:2px!important}.crewlink-tabbar :deep(.k-tabbar-link-label){font-size:9px;line-height:11px}.crewlink-tabbar :deep(.k-icon){width:23px;height:23px}.crewlink-tabbar :deep(.badge){position:absolute;top:-3px;right:-7px;font-size:7px}
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index f8110dd..f9cd31d 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -132,7 +132,7 @@ Locales["en"] = {
groupCreated = "Crew created.", joinedGroup = "You joined the crew.", activeGroupChanged = "Active crew changed.", pendingInvitations = "Invitations", invitedBy = "Invited by @{username}", inviteAccepted = "Invitation accepted.", inviteDeclined = "Invitation declined.",
navigation = "CrewLink navigation", map = "Map", crew = "Crew", pings = "Pings", profile = "Profile", onlineNow = "online now", zoomIn = "Zoom in", zoomOut = "Zoom out", myLocation = "Center my location",
member = "Member", members = "Members", openCrew = "View team", newPing = "New Ping", shareLocation = "Mark a place", locationUnavailable = "Your live location is currently hidden.", activeCrew = "Active private crew", groupSummarySingle = "{online} of {total} member online", groupSummary = "{online} of {total} members online", private = "Private",
- nearby = "Nearby", copyCode = "Copy code", manage = "Manage", refresh = "Refresh", codeCopied = "Invitation code copied.", codeRotated = "A new invitation code is active.",
+ nearby = "Nearby", copyCode = "Copy code", shareInvite = "Invite by message", shareInviteTitle = "Join {group} on CrewLink", shareInviteBody = "Use invitation code {code} to join {group}.", manage = "Manage", refresh = "Refresh", codeCopied = "Invitation code copied.", codeRotated = "A new invitation code is active.",
peopleNearby = "People Nearby", peopleNearbyBody = "Only CrewLink users within {distance} meters appear here.", metersAway = "{distance} m away", invite = "Invite", inviteSent = "Invitation sent to @{username}.", nobodyNearby = "Nobody in range", nobodyNearbyBody = "Move closer to another CrewLink user and scan again.", scanAgain = "Scan Again",
liveCoordination = "Live coordination", noPings = "No active pings", noPingsBody = "Share a meeting point, warning, or target with your crew.", expiresMinutes = "{count} min left", expiresSeconds = "{count} sec left", sharedBy = "Shared by @{username}", setRoute = "Set Route", routeSet = "GPS route set.", pingCreated = "Ping shared with your crew.", pingRemoved = "Ping removed.",
newPingBody = "Share your current position or place the ping at the center of the map.", pingLabel = "Ping label", pingLabelPlaceholder = "e.g. Meet at the garage", placeOnMap = "Use map center", placeOnMapBody = "Otherwise your current position is used.", sharePing = "Share Ping",
diff --git a/sky_phone/source/server/easyshare.lua b/sky_phone/source/server/easyshare.lua
index 26350dc..1673621 100644
--- a/sky_phone/source/server/easyshare.lua
+++ b/sky_phone/source/server/easyshare.lua
@@ -217,6 +217,39 @@ local function canonical_profile(device, app_id, id)
return nil
end
+local function canonical_crewlink_invite(device, invite_code)
+ if not device.account_id
+ or type(invite_code) ~= "string"
+ or #invite_code ~= 8
+ or invite_code:find("[^A-Z0-9]")
+ then
+ return nil
+ end
+ local group = first_row([[
+ SELECT g.`name`, g.`invite_code`
+ FROM `sky_phone_crewlink_profiles` p
+ JOIN `sky_phone_crewlink_memberships` m ON m.`profile_id` = p.`id`
+ JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id`
+ WHERE p.`account_id` = ? AND g.`invite_code` = ?
+ AND m.`role` IN ('owner', 'coordinator')
+ LIMIT 1
+ ]], { tonumber(device.account_id), invite_code })
+ if not group then
+ return nil
+ end
+ local locale = (Locales[Config.Bridge.Locale] or Locales["en"]).Phone.Apps.crewlink
+ local title = locale.shareInviteTitle:gsub("{group}", function() return group.name end)
+ local copy_text = locale.shareInviteBody
+ :gsub("{code}", function() return group.invite_code end)
+ :gsub("{group}", function() return group.name end)
+ return {
+ title = title,
+ subtitle = group.invite_code,
+ copyText = copy_text,
+ link = "skyphone://crewlink/invite/" .. group.invite_code,
+ }
+end
+
local function canonical_post(device, app_id, id)
if app_id == "picstagram" then
local post = first_row([[
@@ -639,8 +672,12 @@ local function sanitize_payload(source, device, data)
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)
+ elseif data.kind == "link" and payload.id then
+ if app_id == "citymarkt" or app_id == "local-pages" then
+ canonical = canonical_post(device, app_id, payload.id)
+ elseif app_id == "crewlink" then
+ canonical = canonical_crewlink_invite(device, payload.id)
+ end
end
if not canonical then
return nil, "unsupported_payload"
From 25ae1b31c1d84ec88d5f4d984e8bbe28717db860 Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:48:51 +0200
Subject: [PATCH 27/63] ADD - share music through messages
---
frontend/src/utils/easyshare.test.ts | 30 ++++++++++++++
frontend/src/utils/easyshare.ts | 28 +++++++++++++
frontend/src/views/apps/MusicApp.vue | 59 ++++++++++++++++++++++------
3 files changed, 104 insertions(+), 13 deletions(-)
diff --git a/frontend/src/utils/easyshare.test.ts b/frontend/src/utils/easyshare.test.ts
index 1960381..5a49f67 100644
--- a/frontend/src/utils/easyshare.test.ts
+++ b/frontend/src/utils/easyshare.test.ts
@@ -4,6 +4,7 @@ import type { EasySharePayload } from '@/types/easyshare'
import {
easyShareCrewLinkInviteCode,
easyShareDestinationAppIds,
+ easyShareMusicTarget,
easyShareRoute,
} from '@/utils/easyshare'
@@ -95,6 +96,35 @@ describe('EasyShare deep links', () => {
easyShareCrewLinkInviteCode('link', '', 'invalid-code'),
).toBeNull()
})
+
+ it.each([
+ [
+ 'track',
+ 'skyphone://music/server/city-after-dark',
+ { id: 'city-after-dark', kind: 'track', source: 'server' },
+ ],
+ [
+ 'track',
+ 'skyphone://music/youtube/music-youtube-1',
+ { id: 'music-youtube-1', kind: 'track', source: 'youtube' },
+ ],
+ [
+ 'playlist',
+ 'skyphone://music/playlist/music-playlist-1',
+ { id: 'music-playlist-1', kind: 'playlist' },
+ ],
+ ] as const)('extracts the exact shared music target', (kind, link, target) => {
+ expect(easyShareMusicTarget(kind, link)).toEqual(target)
+ })
+
+ it('rejects mismatched music share kinds', () => {
+ expect(
+ easyShareMusicTarget(
+ 'playlist',
+ 'skyphone://music/server/city-after-dark',
+ ),
+ ).toBeNull()
+ })
})
describe('EasyShare destination suggestions', () => {
diff --git a/frontend/src/utils/easyshare.ts b/frontend/src/utils/easyshare.ts
index c21ea79..05521ca 100644
--- a/frontend/src/utils/easyshare.ts
+++ b/frontend/src/utils/easyshare.ts
@@ -75,6 +75,34 @@ export function easyShareCrewLinkInviteCode(
return null
}
+export type EasyShareMusicTarget =
+ | { id: string; kind: 'playlist' }
+ | { id: string; kind: 'track'; source: 'server' | 'youtube' }
+
+export function easyShareMusicTarget(
+ kind: unknown,
+ link: unknown,
+): EasyShareMusicTarget | null {
+ if (typeof link !== 'string') return null
+ if (kind === 'track') {
+ const match = link.match(
+ /^skyphone:\/\/music\/(server|youtube)\/([^/?#]+)$/,
+ )
+ if (match) {
+ return {
+ id: match[2],
+ kind: 'track',
+ source: match[1] as 'server' | 'youtube',
+ }
+ }
+ }
+ if (kind === 'playlist') {
+ const match = link.match(/^skyphone:\/\/music\/playlist\/([^/?#]+)$/)
+ if (match) return { id: match[1], kind: 'playlist' }
+ }
+ return null
+}
+
export async function openEasySharePayload(
router: Router,
payload: EasySharePayload,
diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue
index 56b6b96..1ac9a67 100644
--- a/frontend/src/views/apps/MusicApp.vue
+++ b/frontend/src/views/apps/MusicApp.vue
@@ -46,11 +46,13 @@ import {
} from 'lucide-vue-next'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
import { useMusicStore } from '@/stores/music'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
+import { easyShareMusicTarget } from '@/utils/easyshare'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet =
@@ -69,6 +71,7 @@ const MUSIC_SHEET_TRANSITION_MS = 420
const music = useMusicStore()
const easyShare = useEasyShareStore()
const phone = usePhoneStore()
+const route = useRoute()
const activeTab = ref('library')
const activePlaylist = ref(null)
const addMenuOpened = ref(false)
@@ -273,10 +276,11 @@ function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
actionMenuOpened.value = true
}
-function shareTrack(): void {
- const track = actionTrack.value
+function shareTrack(selectedTrack: MusicTrack | null = actionTrack.value): void {
+ const track = selectedTrack
if (!track) return
closeMenus()
+ playerOpened.value = false
easyShare.open({
appId: 'music',
copyText: `${track.title} — ${track.artist}`,
@@ -505,8 +509,24 @@ watch(
},
)
-onMounted(() => {
- void music.load()
+onMounted(async () => {
+ await music.load()
+ const target = easyShareMusicTarget(
+ route.query.easyShareKind,
+ route.query.easyShareLink,
+ )
+ if (!target) return
+ if (target.kind === 'playlist') {
+ const playlist = music.playlists.find((entry) => entry.id === target.id)
+ if (playlist) openPlaylist(playlist)
+ return
+ }
+ const track = music.allTracks.find(
+ (entry) => entry.id === target.id && entry.source === target.source,
+ )
+ if (!track) return
+ await playTrack(track)
+ playerOpened.value = true
})
onBeforeUnmount(() => {
@@ -1271,14 +1291,24 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.music.nowPlaying') }}
-
-
-
+
{
color: #fff;
}
-.music-player > header > :last-child {
+.music-player-header-actions {
justify-self: end;
+ display: flex;
+ align-items: center;
+ gap: 5px;
}
.music-player-art {
From 65e30dc32cb830ab88a1eddbc2ec522bb5d71c57 Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Wed, 12 Aug 2026 09:12:10 +0200
Subject: [PATCH 28/63] ADD - share own contact from Control Center
---
.../src/components/PhoneControlCenter.vue | 53 ++++++++++--
frontend/src/stores/phone.ts | 1 +
frontend/testserver/index.cjs | 15 ++++
sky_phone/config/locales/en.lua | 2 +-
sky_phone/source/client/main.lua | 1 +
sky_phone/source/server/easyshare.lua | 84 +++++++++++++++----
6 files changed, 130 insertions(+), 26 deletions(-)
diff --git a/frontend/src/components/PhoneControlCenter.vue b/frontend/src/components/PhoneControlCenter.vue
index 6c62f6a..d1b1689 100644
--- a/frontend/src/components/PhoneControlCenter.vue
+++ b/frontend/src/components/PhoneControlCenter.vue
@@ -10,6 +10,7 @@ import {
Pause,
Plane,
Play,
+ RadioTower,
Signal,
SkipBack,
SkipForward,
@@ -30,6 +31,8 @@ import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
import { useMusicStore } from '@/stores/music'
+import { useEasyShareStore } from '@/stores/easyshare'
+import type { EasySharePayload } from '@/types/easyshare'
import { nuiCall } from '@/utils/nui'
const props = defineProps<{ opened: boolean }>()
@@ -44,6 +47,7 @@ type ConnectivityPreference =
const phone = usePhoneStore()
const music = useMusicStore()
+const easyShare = useEasyShareStore()
const router = useRouter()
const panel = ref(null)
const brightness = ref(phone.preferences.settings.screenBrightness)
@@ -56,6 +60,7 @@ const volume = ref(
)
const flashlightActive = ref(false)
const flashlightPending = ref(false)
+const easySharePending = ref(false)
const previousAlertVolume = ref(volume.value || 75)
const inactiveGlassColors = {
@@ -87,6 +92,10 @@ const flashlightColors = computed(() =>
}
: inactiveGlassColors,
)
+const easyShareColors = {
+ bgIos: 'bg-[#0a84ff]',
+ shadowIos: 'shadow-ios-dark-glass',
+}
const brightnessStyle = computed(() => ({
'--control-level': `${brightness.value}%`,
}))
@@ -202,6 +211,23 @@ async function toggleFlashlight(): Promise {
flashlightPending.value = false
}
+async function shareOwnContact(): Promise {
+ if (easySharePending.value) return
+ easySharePending.value = true
+ const response = await nuiCall('easyshare:own-contact')
+ easySharePending.value = false
+ if (!response.success || !response.data) {
+ console.error(
+ '[ControlCenter] Could not load own EasyShare contact.',
+ response.error,
+ )
+ return
+ }
+ emit('close')
+ easyShare.open(response.data)
+ await easyShare.showNearby()
+}
+
function openApp(path: string): void {
emit('close')
void router.push(path)
@@ -360,9 +386,7 @@ onBeforeUnmount(() => {
-
+
{
+
+
+
+
+
+
{
justify-content: center;
}
-.control-center__round-control--wide {
- grid-column: 1 / span 2;
-}
-
.control-center__round-control > span,
.control-center__quick-action > span {
width: 100%;
@@ -746,6 +780,11 @@ onBeforeUnmount(() => {
color: #ff453a;
}
+.control-center__round-button--easyshare {
+ background: #0a84ff !important;
+ box-shadow: 0 8px 22px rgb(10 132 255 / 34%) !important;
+}
+
.control-center__focus-button {
display: flex;
grid-column: 1 / span 2;
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 66b7e9c..f7adedd 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -3375,6 +3375,7 @@ const defaultLocales: LocaleTree = {
camera: 'Camera',
cellular: 'Cellular Data',
close: 'Close Control Center',
+ easyShareContact: 'Share my contact with nearby players',
flashlight: 'Flashlight',
focus: 'Focus',
label: 'Control Center',
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index 65e699d..ea32d60 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -6868,6 +6868,21 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
+ if (endpoint === 'easyshare:own-contact') {
+ response.json({
+ success: true,
+ data: {
+ appId: 'phone',
+ copyText: 'Alex Morgan\n5551234567',
+ id: 'self',
+ kind: 'contact',
+ meta: { name: 'Alex Morgan', phoneNumber: '5551234567' },
+ subtitle: '5551234567',
+ title: 'Alex Morgan',
+ },
+ })
+ return
+ }
if (endpoint === 'easyshare:set-visibility') {
easyShareVisibility = request.body.visibility
response.json({ success: true, data: { visibility: easyShareVisibility } })
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index f9cd31d..6a7cfe0 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -72,7 +72,7 @@ Locales["en"] = {
},
ControlCenter = {
airplaneMode = "Airplane Mode", bluetooth = "Bluetooth", brightness = "Brightness", calculator = "Calculator",
- camera = "Camera", cellular = "Cellular Data", close = "Close Control Center", flashlight = "Flashlight",
+ camera = "Camera", cellular = "Cellular Data", close = "Close Control Center", easyShareContact = "Share my contact with nearby players", flashlight = "Flashlight",
focus = "Focus", label = "Control Center", media = "Media", muteRingtone = "Mute ringtone and notifications", next = "Next track", notPlaying = "Not Playing",
open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions",
timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi",
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 047e897..4d73d97 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -217,6 +217,7 @@ local server_callbacks = {
"messages:delete",
"messages:gifs",
"easyshare:bootstrap",
+ "easyshare:own-contact",
"easyshare:set-visibility",
"easyshare:request",
"easyshare:respond",
diff --git a/sky_phone/source/server/easyshare.lua b/sky_phone/source/server/easyshare.lua
index 1673621..44d694b 100644
--- a/sky_phone/source/server/easyshare.lua
+++ b/sky_phone/source/server/easyshare.lua
@@ -604,25 +604,36 @@ local function sanitize_payload(source, device, data)
local condition, owner_params = owner_condition(device)
if data.kind == "contact" and type(payload.id) == "string" then
- local params = { payload.id }
- append_params(params, owner_params)
- local rows = Bridge.Database.Query(([[
- SELECT `name`, `phone_number`, `organization`, `notes`
- FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1
- ]]):format(condition), params)
- local contact = rows[1]
- if not contact then
- return nil, "not_owned"
+ if app_id == "phone" and payload.id == "self" then
+ local contact = canonical_own_contact(source, device)
+ if not contact then
+ return nil, "sim_required"
+ end
+ payload.title = contact.title
+ payload.subtitle = contact.subtitle
+ payload.copyText = contact.copyText
+ payload.meta = contact.meta
+ else
+ local params = { payload.id }
+ append_params(params, owner_params)
+ local rows = Bridge.Database.Query(([[
+ SELECT `name`, `phone_number`, `organization`, `notes`
+ FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1
+ ]]):format(condition), params)
+ local contact = rows[1]
+ if not contact then
+ return nil, "not_owned"
+ end
+ payload.title = contact.name
+ payload.subtitle = contact.phone_number
+ payload.copyText = ("%s\n%s"):format(contact.name, contact.phone_number)
+ payload.meta = {
+ name = contact.name,
+ notes = contact.notes,
+ organization = contact.organization,
+ phoneNumber = contact.phone_number,
+ }
end
- payload.title = contact.name
- payload.subtitle = contact.phone_number
- payload.copyText = ("%s\n%s"):format(contact.name, contact.phone_number)
- payload.meta = {
- name = contact.name,
- notes = contact.notes,
- organization = contact.organization,
- phoneNumber = contact.phone_number,
- }
elseif data.kind == "note" and type(payload.id) == "string" then
local params = { payload.id }
append_params(params, owner_params)
@@ -808,6 +819,25 @@ local function apply_received_payload(transfer)
return true
end
+local function canonical_own_contact(source, device)
+ if not device.phone_number then
+ return nil
+ end
+ local name = display_name(source)
+ return {
+ appId = "phone",
+ kind = "contact",
+ id = "self",
+ title = name,
+ subtitle = device.phone_number,
+ copyText = ("%s\n%s"):format(name, device.phone_number),
+ meta = {
+ name = name,
+ phoneNumber = device.phone_number,
+ },
+ }
+end
+
local function advance_transfer(id)
local transfer = active_transfers[id]
if not transfer or transfer.status ~= "transferring" then
@@ -890,6 +920,24 @@ Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
}
end)
+Bridge.Callbacks.Register("sky_phone:easyshare:own-contact", function(source)
+ if not Config.EasyShare.Enabled then
+ return { success = false, error = "disabled" }
+ end
+ if not SkyPhone.AllowOperation(source, "easyshare_own_contact", 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
+ end
+ local payload = canonical_own_contact(source, device)
+ if not payload then
+ return { success = false, error = "sim_required" }
+ end
+ return { success = true, data = payload }
+end)
+
Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data)
if not Config.EasyShare.Enabled then
return { success = false, error = "disabled" }
From 0fba77a89cfa2aba5a4843ebd08c9c59833a53eb Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Wed, 12 Aug 2026 09:21:22 +0200
Subject: [PATCH 29/63] CLN - simplify EasyShare nearby view
---
frontend/src/components/EasyShareSheet.vue | 20 ++------------------
1 file changed, 2 insertions(+), 18 deletions(-)
diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue
index 1d24ad2..9e83d30 100644
--- a/frontend/src/components/EasyShareSheet.vue
+++ b/frontend/src/components/EasyShareSheet.vue
@@ -1,12 +1,11 @@
@@ -234,10 +257,15 @@ onBeforeUnmount(() => {
type="button"
:aria-label="getPhoneAppLabel(app, phone.t)"
:aria-disabled="!app.route"
+ :aria-keyshortcuts="
+ editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
+ "
@click="launch"
@contextmenu.prevent
+ @keydown="onKeydown"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
+ @lostpointercapture="cancelPointerDrag"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
diff --git a/frontend/src/components/CustomAppFrame.vue b/frontend/src/components/CustomAppFrame.vue
index bc6d973..135be23 100644
--- a/frontend/src/components/CustomAppFrame.vue
+++ b/frontend/src/components/CustomAppFrame.vue
@@ -447,6 +447,8 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
right: auto;
bottom: auto;
left: 50%;
+ width: 827px;
+ height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
diff --git a/frontend/src/components/DarkChatSelect.vue b/frontend/src/components/DarkChatSelect.vue
index 804ae57..baa33d1 100644
--- a/frontend/src/components/DarkChatSelect.vue
+++ b/frontend/src/components/DarkChatSelect.vue
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
}
function closeFromEscape(event: KeyboardEvent): void {
- if (event.key === 'Escape') opened.value = false
+ if (event.key !== 'Escape' || !opened.value) return
+ event.preventDefault()
+ event.stopPropagation()
+ opened.value = false
}
onMounted(() => {
diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue
index 9e83d30..48da343 100644
--- a/frontend/src/components/EasyShareSheet.vue
+++ b/frontend/src/components/EasyShareSheet.vue
@@ -9,7 +9,7 @@ import {
UserRound,
X,
} from 'lucide-vue-next'
-import { computed, ref } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
@@ -30,6 +30,7 @@ import {
easyShareDestinationAppIds,
openEasySharePayload,
} from '@/utils/easyshare'
+import { consumeEscape } from '@/utils/keyboard'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
@@ -113,11 +114,8 @@ const shareApps = computed(() =>
return app ? [{ app, id }] : []
}),
)
-const sheetStyle = computed(() => ({
- transform: easyShare.opened
- ? `translateY(calc(-100% + ${dragOffset.value}px))`
- : undefined,
- transitionDuration: dragging.value ? '0ms' : undefined,
+const hostStyle = computed(() => ({
+ '--easyshare-drag-offset': `${dragOffset.value}px`,
}))
function label(key: string, params?: Record): string {
@@ -155,6 +153,11 @@ function close(): void {
easyShare.close()
}
+function onKeydown(event: KeyboardEvent): void {
+ if (!easyShare.opened || !consumeEscape(event)) return
+ close()
+}
+
function beginDrag(event: PointerEvent): void {
if (!easyShare.opened || event.button !== 0) return
dragPointerId = event.pointerId
@@ -240,17 +243,22 @@ async function openTransfer(transfer: EasyShareTransfer): Promise {
close()
await openEasySharePayload(router, transfer.payload)
}
+
+onMounted(() => window.addEventListener('keydown', onKeydown, true))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true))
@@ -407,7 +415,9 @@ async function openTransfer(transfer: EasyShareTransfer): Promise {
diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts
index 338c39b..52e1062 100644
--- a/frontend/src/stores/banking.test.ts
+++ b/frontend/src/stores/banking.test.ts
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
-import { nuiCall } from '@/utils/nui'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
@@ -60,4 +60,26 @@ describe('banking store', () => {
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
+
+ it('does not let an older response overwrite the newest overview', async () => {
+ let resolveOlder!: (response: NuiResponse) => void
+ const olderResponse = new Promise>(
+ (resolve) => {
+ resolveOlder = resolve
+ },
+ )
+ const newest = { ...overview, bank: 23000 }
+ mockNuiCall
+ .mockReturnValueOnce(olderResponse)
+ .mockResolvedValueOnce({ data: newest, success: true })
+ const banking = useBankingStore()
+
+ const olderRequest = banking.load()
+ await banking.load()
+ resolveOlder({ data: { ...overview, bank: 1 }, success: true })
+ await olderRequest
+
+ expect(banking.overview).toEqual(newest)
+ expect(banking.isLoading).toBe(false)
+ })
})
diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts
index dab3572..4c8ba80 100644
--- a/frontend/src/stores/banking.ts
+++ b/frontend/src/stores/banking.ts
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
error: '',
isLoading: false,
overview: null as BankingOverview | null,
+ pendingRequests: 0,
+ requestGeneration: 0,
}),
actions: {
async load(): Promise {
+ const generation = ++this.requestGeneration
+ this.pendingRequests += 1
this.isLoading = true
- const response = await nuiCall('banking:overview')
- this.isLoading = false
+ const response = await nuiCall('banking:overview').finally(
+ () => {
+ this.pendingRequests = Math.max(0, this.pendingRequests - 1)
+ this.isLoading = this.pendingRequests > 0
+ },
+ )
+ if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
amount: number,
phoneNumber?: string,
): Promise> {
+ const generation = ++this.requestGeneration
+ this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall(`banking:${action}`, {
amount,
...(phoneNumber === undefined ? {} : { phoneNumber }),
+ }).finally(() => {
+ this.pendingRequests = Math.max(0, this.pendingRequests - 1)
+ this.isLoading = this.pendingRequests > 0
})
- this.isLoading = false
+ if (generation !== this.requestGeneration) return response
if (response.success && response.data) {
this.overview = response.data
this.error = ''
diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts
index bcf80cc..17e7a3a 100644
--- a/frontend/src/stores/mail.test.ts
+++ b/frontend/src/stores/mail.test.ts
@@ -1,9 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
-import type { MailCounts, MailListItem } from '@/types/mail'
-import { nuiCall } from '@/utils/nui'
+import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
@@ -45,7 +46,7 @@ describe('mail store', () => {
success: true,
})
.mockResolvedValueOnce({
- data: { hasMore: false, items: [listItem(2)] },
+ data: { hasMore: false, items: [listItem(2)], offset: 0 },
success: true,
})
@@ -129,4 +130,103 @@ describe('mail store', () => {
expect(mail.folder).toBe('inbox')
expect(mail.search).toBe('')
})
+
+ it('ignores an older folder response after a newer navigation', async () => {
+ let resolveOlder!: (response: NuiResponse) => void
+ const olderResponse = new Promise>(
+ (resolve) => {
+ resolveOlder = resolve
+ },
+ )
+ mockNuiCall
+ .mockReturnValueOnce(olderResponse)
+ .mockResolvedValueOnce({
+ data: { hasMore: false, items: [listItem(2)] },
+ success: true,
+ })
+ const mail = useMailStore()
+
+ const olderRequest = mail.loadFolder('inbox')
+ await mail.loadFolder('sent')
+ resolveOlder({
+ data: { hasMore: false, items: [listItem(1)], offset: 0 },
+ success: true,
+ })
+ await olderRequest
+
+ expect(mail.folder).toBe('sent')
+ expect(mail.items.map((item) => item.id)).toEqual([2])
+ expect(mail.loading).toBe(false)
+ })
+
+ it('ignores mailbox counts returned after the session was cleared', async () => {
+ let resolveCounts!: (response: NuiResponse) => void
+ mockNuiCall.mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveCounts = resolve
+ }),
+ )
+ const mail = useMailStore()
+
+ const bootstrap = mail.bootstrap('alex@ifruit.com')
+ await mail.bootstrap('')
+ resolveCounts({ data: counts, success: true })
+ await bootstrap
+
+ expect(mail.accountEmail).toBe('')
+ expect(mail.counts).toEqual({
+ drafts: 0,
+ inbox: 0,
+ sent: 0,
+ trash: 0,
+ unread: 0,
+ })
+ })
+
+ it('ignores a late login after the mailbox session was cleared', async () => {
+ let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
+ mockNuiCall.mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveLogin = resolve
+ }),
+ )
+ const mail = useMailStore()
+ const account = useAccountStore()
+
+ const login = mail.login('alex', 'secret')
+ await mail.bootstrap('')
+ resolveLogin({
+ data: { devices: [], email: 'alex@ifruit.com' },
+ success: true,
+ })
+ await login
+
+ expect(mail.accountEmail).toBe('')
+ expect(account.email).toBe('')
+ })
+
+ it('ignores a late login after an external mailbox session change', async () => {
+ let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
+ mockNuiCall
+ .mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveLogin = resolve
+ }),
+ )
+ .mockResolvedValueOnce({ data: counts, success: true })
+ const mail = useMailStore()
+ const account = useAccountStore()
+
+ const login = mail.login('alex', 'secret')
+ account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
+ await mail.bootstrap('morgan@ifruit.com')
+ resolveLogin({
+ data: { devices: [], email: 'alex@ifruit.com' },
+ success: true,
+ })
+ await login
+
+ expect(mail.accountEmail).toBe('morgan@ifruit.com')
+ expect(account.email).toBe('morgan@ifruit.com')
+ })
})
diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts
index 238bea6..01a4c5b 100644
--- a/frontend/src/stores/mail.ts
+++ b/frontend/src/stores/mail.ts
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
const items = ref([])
const loading = ref(false)
const search = ref('')
+ let authenticationGeneration = 0
+ let folderRequestGeneration = 0
+ let sessionGeneration = 0
function clearSession(): void {
+ authenticationGeneration += 1
+ sessionGeneration += 1
+ folderRequestGeneration += 1
accountEmail.value = ''
counts.value = emptyCounts()
items.value = []
hasMore.value = false
folder.value = 'inbox'
search.value = ''
+ loading.value = false
}
async function bootstrap(email: string): Promise {
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
clearSession()
return
}
+ authenticationGeneration += 1
+ sessionGeneration += 1
+ folderRequestGeneration += 1
accountEmail.value = email
await refreshCounts()
}
async function login(email: string, password: string) {
+ const generation = ++authenticationGeneration
const response = await nuiCall('mail:login', {
email,
password,
})
- if (response.success && response.data) {
+ if (
+ generation === authenticationGeneration &&
+ response.success &&
+ response.data
+ ) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
}
async function register(email: string, password: string) {
+ const generation = ++authenticationGeneration
const response = await nuiCall('mail:register', {
email,
password,
})
- if (response.success && response.data) {
+ if (
+ generation === authenticationGeneration &&
+ response.success &&
+ response.data
+ ) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
}
async function logout(): Promise {
+ const generation = ++authenticationGeneration
if (accountEmail.value) {
const response = await nuiCall('mail:logout')
+ if (generation !== authenticationGeneration) return
if (response.success) account.hydrate(null)
}
+ if (generation !== authenticationGeneration) return
clearSession()
}
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
nextSearch = '',
append = false,
): Promise {
+ const generation = ++folderRequestGeneration
+ const session = sessionGeneration
loading.value = true
const offset = append ? items.value.length : 0
const response = await nuiCall('mail:list', {
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
offset,
search: nextSearch,
})
- loading.value = false
+ if (generation === folderRequestGeneration) loading.value = false
+ if (
+ generation !== folderRequestGeneration ||
+ session !== sessionGeneration
+ ) {
+ return false
+ }
if (!response.success || !response.data) return false
folder.value = nextFolder
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
}
async function refreshCounts(): Promise {
+ const email = accountEmail.value
+ const session = sessionGeneration
const response = await nuiCall('mail:counts')
- if (response.success && response.data) counts.value = response.data
+ if (
+ session === sessionGeneration &&
+ email === accountEmail.value &&
+ response.success &&
+ response.data
+ ) {
+ counts.value = response.data
+ }
}
async function openMessage(id: number): Promise {
diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts
index e38b885..59eff5f 100644
--- a/frontend/src/stores/notifications.test.ts
+++ b/frontend/src/stores/notifications.test.ts
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ MAX_LOCK_SCREEN_NOTIFICATIONS,
useNotificationsStore,
type PhoneNotificationDevice,
} from '@/stores/notifications'
@@ -256,4 +257,26 @@ describe('notifications store', () => {
notifications.clearLockScreen()
expect(notifications.lockScreenNotifications).toEqual([])
})
+
+ it('bounds persisted lock screen history to the newest notifications', () => {
+ openPhone('111')
+ const notifications = useNotificationsStore()
+ const items = Array.from(
+ { length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 },
+ (_, index) => ({
+ appId: 'mail' as const,
+ id: `saved-${index}`,
+ text: `Message ${index}`,
+ title: 'Mail',
+ }),
+ )
+
+ notifications.hydrate({ items, version: 1 }, '111')
+
+ expect(notifications.lockScreenNotifications).toHaveLength(
+ MAX_LOCK_SCREEN_NOTIFICATIONS,
+ )
+ expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59')
+ expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10')
+ })
})
diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts
index 1487a2c..2777000 100644
--- a/frontend/src/stores/notifications.ts
+++ b/frontend/src/stores/notifications.ts
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
version: 1
}
+export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
+
const timeoutHandles = new Map>()
const stopToneHandles = new Map void>()
const persistenceQueues = new Map>()
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
for (const notification of stored) merged.set(notification.id, notification)
for (const notification of lockScreenQueues.value[imei] ?? [])
merged.set(notification.id, notification)
- lockScreenQueues.value[imei] = [...merged.values()]
+ lockScreenQueues.value[imei] = [...merged.values()].slice(
+ -MAX_LOCK_SCREEN_NOTIFICATIONS,
+ )
persist(imei)
}
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
function remember(notification: PhoneNotification): void {
const imei = notification.device?.imei ?? phone.device?.imei
if (!imei) return
- const notifications = lockScreenQueues.value[imei] ?? []
- notifications.push(notification)
- lockScreenQueues.value[imei] = notifications
+ lockScreenQueues.value[imei] = [
+ ...(lockScreenQueues.value[imei] ?? []),
+ notification,
+ ].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
persist(imei)
}
diff --git a/frontend/src/stores/phone-persistence.test.ts b/frontend/src/stores/phone-persistence.test.ts
new file mode 100644
index 0000000..1c4027a
--- /dev/null
+++ b/frontend/src/stores/phone-persistence.test.ts
@@ -0,0 +1,170 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { usePhoneStore } from '@/stores/phone'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({
+ nuiCall: vi.fn(),
+}))
+
+const mockNuiCall = vi.mocked(nuiCall)
+
+function deferredResponse(): {
+ promise: Promise>
+ resolve: (response: NuiResponse) => void
+} {
+ let resolve!: (response: NuiResponse) => void
+ const promise = new Promise>((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+}
+
+function openPhone(imei: string, token: string, revision: number): void {
+ usePhoneStore().open({
+ device: {
+ data: { settings: { payload: {}, revision } },
+ imei,
+ name: `Phone ${imei}`,
+ sim: null,
+ },
+ token,
+ })
+}
+
+describe('phone device persistence scope', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ matchMedia: vi.fn(() => ({ matches: false })),
+ })
+ setActivePinia(createPinia())
+ mockNuiCall.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('does not apply a late save response to a newer device session', async () => {
+ const stale = deferredResponse<{ revision: number }>()
+ mockNuiCall.mockReturnValueOnce(stale.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 2)
+
+ phone.saveDeviceNamespace('settings', { value: 'old' })
+ await Promise.resolve()
+ expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
+ imei: '111',
+ namespace: 'settings',
+ payload: { value: 'old' },
+ revision: 2,
+ sessionToken: 'session-a',
+ })
+
+ openPhone('222', 'session-b', 7)
+ stale.resolve({ data: { revision: 3 }, success: true })
+ await stale.promise
+ await Promise.resolve()
+
+ expect(phone.device?.imei).toBe('222')
+ expect(phone.deviceRevisions.settings).toBe(7)
+ })
+
+ it('drops queued writes from an obsolete device generation', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall.mockReturnValueOnce(first.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ openPhone('222', 'session-b', 0)
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(1)
+ })
+
+ it('flushes every queued write after a normal visibility close', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValueOnce({ data: { revision: 2 }, success: true })
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ phone.close()
+ const flushed = phone.flushDevicePersistence()
+
+ first.resolve({ data: { revision: 1 }, success: true })
+ await flushed
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
+ imei: '111',
+ namespace: 'settings',
+ payload: { order: 2 },
+ revision: 1,
+ sessionToken: 'session-a',
+ })
+ expect(phone.deviceRevisions.settings).toBe(2)
+ })
+
+ it('keeps queued writes scoped across a same-session bootstrap update', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValueOnce({ data: { revision: 2 }, success: true })
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ openPhone('111', 'session-a', 1)
+ await phone.flushDevicePersistence()
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(phone.deviceRevisions.settings).toBe(2)
+ })
+
+ it('waits for writes queued while a persistence flush is in progress', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ const queuedDuringFlush = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockReturnValueOnce(queuedDuringFlush.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ await Promise.resolve()
+ let flushCompleted = false
+ const flushed = phone.flushDevicePersistence().then(() => {
+ flushCompleted = true
+ })
+ phone.saveDeviceNamespace('widgets', { order: 2 })
+ await Promise.resolve()
+
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ expect(flushCompleted).toBe(false)
+
+ queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
+ await flushed
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(phone.deviceRevisions.widgets).toBe(1)
+ })
+})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 8ad26ae..b708598 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
+ clampPhoneScale,
ensureAppNotificationPreferences,
parsePhonePreferences,
type AppNotificationPreferences,
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
}
const namespaceQueues = new Map>()
+let nextPersistenceSession = 0
const companiesFallbackLocales = {
name: 'Companies',
@@ -3606,11 +3608,14 @@ export const usePhoneStore = defineStore('phone', {
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record,
+ deviceSessionToken: null as string | null,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
+ persistenceGeneration: 0,
+ persistenceSession: ++nextPersistenceSession,
security: {
enabled: false,
length: null,
@@ -3631,6 +3636,15 @@ export const usePhoneStore = defineStore('phone', {
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
+ const nextImei = payload.device?.imei ?? this.device?.imei ?? null
+ const nextToken = payload.token ?? this.deviceSessionToken
+ if (
+ nextImei !== (this.device?.imei ?? null) ||
+ nextToken !== this.deviceSessionToken
+ ) {
+ this.persistenceGeneration += 1
+ }
+ this.deviceSessionToken = nextToken
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
@@ -3641,6 +3655,13 @@ export const usePhoneStore = defineStore('phone', {
}
this.isOpen = true
},
+ endDeviceSession(): void {
+ this.close()
+ if (this.deviceSessionToken !== null) {
+ this.deviceSessionToken = null
+ this.persistenceGeneration += 1
+ }
+ },
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
@@ -3654,22 +3675,59 @@ export const usePhoneStore = defineStore('phone', {
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
- const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
+ const imei = this.device?.imei
+ if (!imei) {
+ console.error(
+ `[Phone persistence] Could not save ${namespace} without an active device.`,
+ )
+ return
+ }
+ const generation = this.persistenceGeneration
+ const session = this.persistenceSession
+ const token = this.deviceSessionToken
+ const queuedPayload = cloneJsonData(payload)
+ const queueKey = `${session}:${generation}:${imei}:${namespace}`
+ const isCurrentScope = (): boolean =>
+ this.persistenceSession === session &&
+ this.persistenceGeneration === generation &&
+ this.device?.imei === imei &&
+ this.deviceSessionToken === token
+ const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
+ if (!isCurrentScope()) return
const response = await nuiCall<{ revision: number }>('device:save', {
+ imei,
namespace,
- payload,
+ payload: queuedPayload,
revision: this.deviceRevisions[namespace] ?? 0,
+ sessionToken: token,
})
- if (response.success && response.data) {
- this.deviceRevisions[namespace] = response.data.revision
+ if (
+ isCurrentScope() &&
+ response.success &&
+ Number.isInteger(response.data?.revision) &&
+ Number(response.data?.revision) >= 0
+ ) {
+ this.deviceRevisions[namespace] = Number(response.data?.revision)
}
})
const tracked = queued.finally(() => {
- if (namespaceQueues.get(namespace) === tracked)
- namespaceQueues.delete(namespace)
+ if (namespaceQueues.get(queueKey) === tracked)
+ namespaceQueues.delete(queueKey)
})
- namespaceQueues.set(namespace, tracked)
+ namespaceQueues.set(queueKey, tracked)
+ },
+ async flushDevicePersistence(): Promise {
+ const imei = this.device?.imei
+ if (!imei) return
+ const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
+ while (true) {
+ const activeQueues = [...namespaceQueues.entries()]
+ .filter(([key]) => key.startsWith(queuePrefix))
+ .map(([, queue]) => queue)
+ if (!activeQueues.length) return
+ await Promise.all(activeQueues)
+ }
},
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
@@ -3695,7 +3753,11 @@ export const usePhoneStore = defineStore('phone', {
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
- this.preferences.settings[key] = value
+ this.preferences.settings[key] = (
+ key === 'phoneScale'
+ ? clampPhoneScale(Number(value))
+ : value
+ ) as PhonePreferencesV1['settings'][K]
this.saveDeviceNamespace('settings', this.preferences)
},
setAlertVolumes(value: number): void {
diff --git a/frontend/src/utils/gameView.test.ts b/frontend/src/utils/gameView.test.ts
index b218a9c..39112dd 100644
--- a/frontend/src/utils/gameView.test.ts
+++ b/frontend/src/utils/gameView.test.ts
@@ -1,6 +1,6 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
-import { gameViewGeometry } from '@/utils/gameView'
+import { createGameView, gameViewGeometry } from '@/utils/gameView'
describe('gameViewGeometry', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
@@ -60,3 +60,92 @@ describe('gameViewGeometry', () => {
])
})
})
+
+describe('createGameView', () => {
+ it('recreates graphics resources and resumes after context restoration', () => {
+ const gl = {
+ ARRAY_BUFFER: 1,
+ CLAMP_TO_EDGE: 2,
+ COLOR_BUFFER_BIT: 4,
+ COMPILE_STATUS: 5,
+ DYNAMIC_DRAW: 6,
+ FLOAT: 7,
+ FRAGMENT_SHADER: 8,
+ LINK_STATUS: 9,
+ MIRRORED_REPEAT: 10,
+ NEAREST: 11,
+ REPEAT: 12,
+ RGBA: 13,
+ STATIC_DRAW: 14,
+ TEXTURE_2D: 15,
+ TEXTURE_MAG_FILTER: 16,
+ TEXTURE_MIN_FILTER: 17,
+ TEXTURE_WRAP_S: 18,
+ TEXTURE_WRAP_T: 19,
+ TRIANGLE_STRIP: 20,
+ UNSIGNED_BYTE: 21,
+ VERTEX_SHADER: 22,
+ attachShader: vi.fn(),
+ bindBuffer: vi.fn(),
+ bindTexture: vi.fn(),
+ bufferData: vi.fn(),
+ clear: vi.fn(),
+ clearColor: vi.fn(),
+ compileShader: vi.fn(),
+ createBuffer: vi.fn(() => ({})),
+ createProgram: vi.fn(() => ({})),
+ createShader: vi.fn(() => ({})),
+ createTexture: vi.fn(() => ({})),
+ deleteBuffer: vi.fn(),
+ deleteProgram: vi.fn(),
+ deleteShader: vi.fn(),
+ deleteTexture: vi.fn(),
+ drawArrays: vi.fn(),
+ enableVertexAttribArray: vi.fn(),
+ finish: vi.fn(),
+ getAttribLocation: vi.fn((_program, name: string) =>
+ name === 'a_position' ? 0 : 1,
+ ),
+ getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
+ getProgramInfoLog: vi.fn(() => ''),
+ getProgramParameter: vi.fn(() => true),
+ getShaderInfoLog: vi.fn(() => ''),
+ getShaderParameter: vi.fn(() => true),
+ getUniformLocation: vi.fn(() => ({})),
+ linkProgram: vi.fn(),
+ shaderSource: vi.fn(),
+ texImage2D: vi.fn(),
+ texParameterf: vi.fn(),
+ uniform1i: vi.fn(),
+ useProgram: vi.fn(),
+ vertexAttribPointer: vi.fn(),
+ viewport: vi.fn(),
+ }
+ const canvas = Object.assign(new EventTarget(), {
+ getContext: () => gl,
+ height: 0,
+ width: 0,
+ }) as unknown as HTMLCanvasElement
+ const restored = vi.fn()
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ vi.spyOn(console, 'info').mockImplementation(() => undefined)
+ const view = createGameView(canvas, { onContextRestored: restored })
+ view.resize(540, 720, 1920, 1080, 2)
+
+ const lost = new Event('webglcontextlost', { cancelable: true })
+ canvas.dispatchEvent(lost)
+ expect(lost.defaultPrevented).toBe(true)
+ expect(view.isLost()).toBe(true)
+
+ canvas.dispatchEvent(new Event('webglcontextrestored'))
+ expect(view.isLost()).toBe(false)
+ expect(restored).toHaveBeenCalledOnce()
+ expect(gl.createProgram).toHaveBeenCalledTimes(2)
+ expect(canvas.width).toBe(540)
+ expect(canvas.height).toBe(720)
+
+ view.render()
+ expect(gl.drawArrays).toHaveBeenCalledOnce()
+ view.dispose()
+ })
+})
diff --git a/frontend/src/utils/gameView.ts b/frontend/src/utils/gameView.ts
index 9dd0ef1..1485c98 100644
--- a/frontend/src/utils/gameView.ts
+++ b/frontend/src/utils/gameView.ts
@@ -31,6 +31,8 @@ export interface GameView {
}
export interface GameViewOptions {
+ onContextLost?: () => void
+ onContextRestored?: () => void
preserveDrawingBuffer?: boolean
}
@@ -113,80 +115,180 @@ export function createGameView(
let lost = false
let disposed = false
+ let program: WebGLProgram | null = null
+ let positionBuffer: WebGLBuffer | null = null
+ let texcoordBuffer: WebGLBuffer | null = null
+ let texture: WebGLTexture | null = null
+ let lastSize: {
+ height: number
+ sourceHeight: number
+ sourceWidth: number
+ width: number
+ zoom: number
+ } | null = null
+
+ const releaseResources = (): void => {
+ if (positionBuffer) gl.deleteBuffer(positionBuffer)
+ if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer)
+ if (texture) gl.deleteTexture(texture)
+ if (program) gl.deleteProgram(program)
+ positionBuffer = null
+ texcoordBuffer = null
+ texture = null
+ program = null
+ }
+
+ const initializeResources = (): void => {
+ releaseResources()
+ const nextProgram = gl.createProgram()
+ if (!nextProgram) throw new Error('game_view_program_unavailable')
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
+ const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
+ gl.attachShader(nextProgram, vertexShader)
+ gl.attachShader(nextProgram, fragmentShader)
+ gl.linkProgram(nextProgram)
+ gl.deleteShader(vertexShader)
+ gl.deleteShader(fragmentShader)
+ if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) {
+ const error = gl.getProgramInfoLog(nextProgram)
+ gl.deleteProgram(nextProgram)
+ throw new Error(error || 'game_view_program_failed')
+ }
+ gl.useProgram(nextProgram)
+
+ const positionLocation = gl.getAttribLocation(nextProgram, 'a_position')
+ const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord')
+ if (positionLocation < 0 || texcoordLocation < 0) {
+ gl.deleteProgram(nextProgram)
+ throw new Error('game_view_attributes_unavailable')
+ }
+
+ const nextPositionBuffer = gl.createBuffer()
+ const nextTexcoordBuffer = gl.createBuffer()
+ const nextTexture = gl.createTexture()
+ if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) {
+ if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer)
+ if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer)
+ if (nextTexture) gl.deleteTexture(nextTexture)
+ gl.deleteProgram(nextProgram)
+ throw new Error('game_view_resources_unavailable')
+ }
+
+ program = nextProgram
+ positionBuffer = nextPositionBuffer
+ texcoordBuffer = nextTexcoordBuffer
+ texture = nextTexture
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
+ gl.DYNAMIC_DRAW,
+ )
+ gl.enableVertexAttribArray(positionLocation)
+ gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
+ gl.STATIC_DRAW,
+ )
+ gl.enableVertexAttribArray(texcoordLocation)
+ gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
+
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ 1,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array([0, 0, 0, 255]),
+ )
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+ // CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
+ // game backbuffer. These calls are intentionally not redundant.
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
+ gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
+ gl.clearColor(0, 0, 0, 1)
+ }
+
+ const applySize = (): void => {
+ if (!lastSize || !positionBuffer || !texcoordBuffer) return
+ const geometry = gameViewGeometry(
+ lastSize.sourceWidth,
+ lastSize.sourceHeight,
+ lastSize.width,
+ lastSize.height,
+ lastSize.zoom,
+ )
+ canvas.width = lastSize.width
+ canvas.height = lastSize.height
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
+ gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
+ gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ geometry.textureCoordinates,
+ gl.DYNAMIC_DRAW,
+ )
+ gl.viewport(0, 0, lastSize.width, lastSize.height)
+ }
+
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
+ options.onContextLost?.()
+ }
+ const onContextRestored = () => {
+ if (disposed) return
+ try {
+ initializeResources()
+ lost = false
+ applySize()
+ console.info('[Camera] Game-view WebGL context restored.')
+ options.onContextRestored?.()
+ } catch (error) {
+ lost = true
+ console.error('[Camera] Could not restore the game-view WebGL context.', error)
+ }
}
canvas.addEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
-
- const program = gl.createProgram()
- if (!program) throw new Error('game_view_program_unavailable')
- gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
- gl.attachShader(
- program,
- compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
+ canvas.addEventListener(
+ 'webglcontextrestored',
+ onContextRestored as EventListener,
+ false,
)
- gl.linkProgram(program)
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
- throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
+ try {
+ initializeResources()
+ } catch (error) {
+ canvas.removeEventListener(
+ 'webglcontextlost',
+ onContextLost as EventListener,
+ false,
+ )
+ canvas.removeEventListener(
+ 'webglcontextrestored',
+ onContextRestored as EventListener,
+ false,
+ )
+ releaseResources()
+ throw error
}
- gl.useProgram(program)
-
- const positionLocation = gl.getAttribLocation(program, 'a_position')
- const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
- if (positionLocation < 0 || texcoordLocation < 0) {
- throw new Error('game_view_attributes_unavailable')
- }
-
- const positionBuffer = gl.createBuffer()
- gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
- gl.bufferData(
- gl.ARRAY_BUFFER,
- new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
- gl.DYNAMIC_DRAW,
- )
- gl.enableVertexAttribArray(positionLocation)
- gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
-
- const texcoordBuffer = gl.createBuffer()
- gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
- gl.bufferData(
- gl.ARRAY_BUFFER,
- new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
- gl.STATIC_DRAW,
- )
- gl.enableVertexAttribArray(texcoordLocation)
- gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
-
- const texture = gl.createTexture()
- gl.bindTexture(gl.TEXTURE_2D, texture)
- gl.texImage2D(
- gl.TEXTURE_2D,
- 0,
- gl.RGBA,
- 1,
- 1,
- 0,
- gl.RGBA,
- gl.UNSIGNED_BYTE,
- new Uint8Array([0, 0, 0, 255]),
- )
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
- // CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
- // game backbuffer. These calls are intentionally not redundant.
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
- gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
- gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
- gl.clearColor(0, 0, 0, 1)
return {
canvas,
@@ -198,11 +300,18 @@ export function createGameView(
onContextLost as EventListener,
false,
)
+ canvas.removeEventListener(
+ 'webglcontextrestored',
+ onContextRestored as EventListener,
+ false,
+ )
+ if (!lost) releaseResources()
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
- if (disposed || lost) return
+ if (disposed || lost || !program) return
+ gl.useProgram(program)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
@@ -214,25 +323,15 @@ export function createGameView(
sourceHeight = window.innerHeight,
zoom = 1,
) {
- if (disposed || lost) return
- canvas.width = width
- canvas.height = height
- const geometry = gameViewGeometry(
+ lastSize = {
+ height,
sourceWidth,
sourceHeight,
width,
- height,
zoom,
- )
- gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
- gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
- gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
- gl.bufferData(
- gl.ARRAY_BUFFER,
- geometry.textureCoordinates,
- gl.DYNAMIC_DRAW,
- )
- gl.viewport(0, 0, width, height)
+ }
+ if (disposed || lost) return
+ applySize()
},
}
}
diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts
index 72602c6..e0767f8 100644
--- a/frontend/src/utils/homeLayout.test.ts
+++ b/frontend/src/utils/homeLayout.test.ts
@@ -5,6 +5,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
HOME_GRID_PAGE_SIZE,
+ homeKeyboardTarget,
MAX_HOME_GRID_PAGES,
moveHomeApp,
parseHomeLayout,
@@ -134,6 +135,15 @@ describe('home layout', () => {
expect(moved.grid[4]).toBe('notes')
})
+ it('provides bounded keyboard reorder targets without wrapping rows', () => {
+ expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2)
+ expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull()
+ expect(homeKeyboardTarget(defaults, 'grid', 0, 'up')).toBeNull()
+ expect(homeKeyboardTarget(defaults, 'grid', 0, 'down')).toBe(4)
+ expect(homeKeyboardTarget(defaults, 'dock', 1, 'left')).toBe(0)
+ expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
+ })
+
it('shifts occupied grid slots instead of replacing their apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts
index 01bb146..6f01235 100644
--- a/frontend/src/utils/homeLayout.ts
+++ b/frontend/src/utils/homeLayout.ts
@@ -1,6 +1,8 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
+import type { ReorderDirection } from '@/utils/keyboard'
export const HOME_DOCK_CAPACITY = 4
+export const HOME_GRID_COLUMNS = 4
export const HOME_GRID_PAGE_SIZE = 20
export const MAX_HOME_GRID_PAGES = 5
@@ -304,3 +306,31 @@ export function moveHomeApp(
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
return next
}
+
+export function homeKeyboardTarget(
+ layout: HomeLayout,
+ area: HomeArea,
+ sourceIndex: number,
+ direction: ReorderDirection,
+): number | null {
+ const source = layout[area]
+ if (!source[sourceIndex]) return null
+
+ if (area === 'dock') {
+ if (direction !== 'left' && direction !== 'right') return null
+ const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1)
+ return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
+ }
+
+ const column = sourceIndex % HOME_GRID_COLUMNS
+ if (direction === 'left' && column === 0) return null
+ if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null
+ const deltas: Record = {
+ down: HOME_GRID_COLUMNS,
+ left: -1,
+ right: 1,
+ up: -HOME_GRID_COLUMNS,
+ }
+ const targetIndex = sourceIndex + deltas[direction]
+ return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
+}
diff --git a/frontend/src/utils/keyboard.test.ts b/frontend/src/utils/keyboard.test.ts
new file mode 100644
index 0000000..f6b1df9
--- /dev/null
+++ b/frontend/src/utils/keyboard.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ consumeEscape,
+ handleEnterAction,
+ reorderDirectionFromKeyboard,
+} from '@/utils/keyboard'
+
+describe('keyboard interaction', () => {
+ it('does not submit while an IME composition is active', () => {
+ const action = vi.fn()
+ const preventDefault = vi.fn()
+
+ expect(
+ handleEnterAction({ isComposing: true, preventDefault }, action),
+ ).toBe(false)
+ expect(action).not.toHaveBeenCalled()
+ expect(preventDefault).not.toHaveBeenCalled()
+ })
+
+ it('prevents the completed Enter key and runs its action once', () => {
+ const action = vi.fn()
+ const preventDefault = vi.fn()
+
+ expect(
+ handleEnterAction({ isComposing: false, preventDefault }, action),
+ ).toBe(true)
+ expect(preventDefault).toHaveBeenCalledOnce()
+ expect(action).toHaveBeenCalledOnce()
+ })
+
+ it('consumes only an unhandled Escape outside IME composition', () => {
+ const preventDefault = vi.fn()
+ const stopImmediatePropagation = vi.fn()
+
+ expect(
+ consumeEscape({
+ defaultPrevented: false,
+ isComposing: false,
+ key: 'Escape',
+ preventDefault,
+ stopImmediatePropagation,
+ }),
+ ).toBe(true)
+ expect(preventDefault).toHaveBeenCalledOnce()
+ expect(stopImmediatePropagation).toHaveBeenCalledOnce()
+
+ expect(
+ consumeEscape({
+ defaultPrevented: false,
+ isComposing: true,
+ key: 'Escape',
+ preventDefault,
+ stopImmediatePropagation,
+ }),
+ ).toBe(false)
+ })
+
+ it('blocks a second Escape owner on the same event target', () => {
+ const target = new EventTarget()
+ const rootHandler = vi.fn()
+ target.addEventListener('keydown', (event) => {
+ consumeEscape(event as KeyboardEvent)
+ })
+ target.addEventListener('keydown', rootHandler)
+ const event = new Event('keydown', { cancelable: true })
+ Object.defineProperties(event, {
+ isComposing: { value: false },
+ key: { value: 'Escape' },
+ })
+
+ target.dispatchEvent(event)
+
+ expect(event.defaultPrevented).toBe(true)
+ expect(rootHandler).not.toHaveBeenCalled()
+ })
+
+ it('maps only unmodified arrow keys to reorder directions', () => {
+ expect(
+ reorderDirectionFromKeyboard({
+ altKey: false,
+ ctrlKey: false,
+ isComposing: false,
+ key: 'ArrowLeft',
+ metaKey: false,
+ }),
+ ).toBe('left')
+ expect(
+ reorderDirectionFromKeyboard({
+ altKey: false,
+ ctrlKey: true,
+ isComposing: false,
+ key: 'ArrowLeft',
+ metaKey: false,
+ }),
+ ).toBeNull()
+ })
+})
diff --git a/frontend/src/utils/keyboard.ts b/frontend/src/utils/keyboard.ts
new file mode 100644
index 0000000..7d0cbbc
--- /dev/null
+++ b/frontend/src/utils/keyboard.ts
@@ -0,0 +1,47 @@
+export type ReorderDirection = 'down' | 'left' | 'right' | 'up'
+
+export function consumeEscape(
+ event: Pick<
+ KeyboardEvent,
+ | 'defaultPrevented'
+ | 'isComposing'
+ | 'key'
+ | 'preventDefault'
+ | 'stopImmediatePropagation'
+ >,
+): boolean {
+ if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented) {
+ return false
+ }
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ return true
+}
+
+export function handleEnterAction(
+ event: Pick,
+ action: () => unknown,
+): boolean {
+ if (event.isComposing) return false
+ event.preventDefault()
+ void action()
+ return true
+}
+
+export function reorderDirectionFromKeyboard(
+ event: Pick<
+ KeyboardEvent,
+ 'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey'
+ >,
+): ReorderDirection | null {
+ if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) {
+ return null
+ }
+ const directions: Partial> = {
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+ ArrowUp: 'up',
+ }
+ return directions[event.key] ?? null
+}
diff --git a/frontend/src/utils/mediaRecorder.test.ts b/frontend/src/utils/mediaRecorder.test.ts
new file mode 100644
index 0000000..d8b0dde
--- /dev/null
+++ b/frontend/src/utils/mediaRecorder.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ bindMediaRecorderError,
+ setBoundedMapEntry,
+ stopMediaRecorder,
+} from '@/utils/mediaRecorder'
+
+class FakeRecorder extends EventTarget {
+ state: RecordingState = 'recording'
+ stop = vi.fn(() => {
+ this.state = 'inactive'
+ this.dispatchEvent(new Event('stop'))
+ })
+}
+
+describe('media recorder lifecycle', () => {
+ it('resolves from the recorder stop event', async () => {
+ const recorder = new FakeRecorder()
+
+ await stopMediaRecorder(recorder as unknown as MediaRecorder)
+
+ expect(recorder.stop).toHaveBeenCalledOnce()
+ expect(recorder.state).toBe('inactive')
+ })
+
+ it('runs recorder error cleanup only for the current generation', () => {
+ const staleRecorder = new FakeRecorder()
+ const currentRecorder = new FakeRecorder()
+ const cleanup = vi.fn()
+ let generation = 1
+ const unbindStale = bindMediaRecorderError(
+ staleRecorder as unknown as MediaRecorder,
+ () => generation === 1,
+ cleanup,
+ )
+
+ generation = 2
+ staleRecorder.dispatchEvent(new Event('error'))
+ expect(cleanup).not.toHaveBeenCalled()
+ unbindStale()
+
+ bindMediaRecorderError(
+ currentRecorder as unknown as MediaRecorder,
+ () => generation === 2,
+ cleanup,
+ )
+ currentRecorder.dispatchEvent(new Event('error'))
+ currentRecorder.dispatchEvent(new Event('error'))
+
+ expect(cleanup).toHaveBeenCalledOnce()
+ })
+
+ it('keeps pending recording buffers bounded and evicts the oldest', () => {
+ const pending = new Map()
+
+ setBoundedMapEntry(pending, 'first', 1, 2)
+ setBoundedMapEntry(pending, 'second', 2, 2)
+ setBoundedMapEntry(pending, 'third', 3, 2)
+
+ expect([...pending.entries()]).toEqual([
+ ['second', 2],
+ ['third', 3],
+ ])
+ })
+})
diff --git a/frontend/src/utils/mediaRecorder.ts b/frontend/src/utils/mediaRecorder.ts
new file mode 100644
index 0000000..87d7e04
--- /dev/null
+++ b/frontend/src/utils/mediaRecorder.ts
@@ -0,0 +1,62 @@
+export function bindMediaRecorderError(
+ recorder: MediaRecorder,
+ isCurrent: () => boolean,
+ onError: (event: Event) => void,
+): () => void {
+ let bound = true
+ const handleError = (event: Event): void => {
+ if (!bound || !isCurrent()) return
+ bound = false
+ recorder.removeEventListener('error', handleError)
+ onError(event)
+ }
+ recorder.addEventListener('error', handleError)
+ return () => {
+ if (!bound) return
+ bound = false
+ recorder.removeEventListener('error', handleError)
+ }
+}
+
+export async function stopMediaRecorder(recorder: MediaRecorder): Promise {
+ if (recorder.state === 'inactive') return
+
+ await new Promise((resolve, reject) => {
+ const cleanup = (): void => {
+ recorder.removeEventListener('stop', onStop)
+ recorder.removeEventListener('error', onError)
+ }
+ const onStop = (): void => {
+ cleanup()
+ resolve()
+ }
+ const onError = (): void => {
+ cleanup()
+ reject(new Error('media_recorder_stop_failed'))
+ }
+
+ recorder.addEventListener('stop', onStop, { once: true })
+ recorder.addEventListener('error', onError, { once: true })
+ try {
+ recorder.stop()
+ } catch (error) {
+ cleanup()
+ reject(error)
+ }
+ })
+}
+
+export function setBoundedMapEntry(
+ entries: Map,
+ key: Key,
+ value: Value,
+ maximumSize: number,
+): void {
+ entries.delete(key)
+ entries.set(key, value)
+ while (entries.size > Math.max(0, maximumSize)) {
+ const oldest = entries.keys().next()
+ if (oldest.done) break
+ entries.delete(oldest.value)
+ }
+}
diff --git a/frontend/src/utils/musicEscape.test.ts b/frontend/src/utils/musicEscape.test.ts
new file mode 100644
index 0000000..47111f2
--- /dev/null
+++ b/frontend/src/utils/musicEscape.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+
+import { musicEscapeLayer } from '@/utils/musicEscape'
+
+const closedState = {
+ actionMenuOpened: false,
+ activeSheet: false,
+ addMenuOpened: false,
+ confirmDeletePlaylist: false,
+ confirmRemoveTrack: false,
+ playerOpened: false,
+}
+
+describe('music Escape ownership', () => {
+ it('owns Escape while either music popover is open', () => {
+ expect(
+ musicEscapeLayer({ ...closedState, addMenuOpened: true }),
+ ).toBe('menu')
+ expect(
+ musicEscapeLayer({ ...closedState, actionMenuOpened: true }),
+ ).toBe('menu')
+ })
+
+ it('keeps a real form sheet above menus and the player', () => {
+ expect(
+ musicEscapeLayer({
+ ...closedState,
+ activeSheet: true,
+ addMenuOpened: true,
+ playerOpened: true,
+ }),
+ ).toBe('sheet')
+ })
+
+ it('does not claim Escape with no music overlay open', () => {
+ expect(musicEscapeLayer(closedState)).toBeNull()
+ })
+})
diff --git a/frontend/src/utils/musicEscape.ts b/frontend/src/utils/musicEscape.ts
new file mode 100644
index 0000000..b6afd04
--- /dev/null
+++ b/frontend/src/utils/musicEscape.ts
@@ -0,0 +1,22 @@
+export type MusicEscapeLayer =
+ | 'delete-playlist-confirmation'
+ | 'menu'
+ | 'player'
+ | 'remove-track-confirmation'
+ | 'sheet'
+
+export function musicEscapeLayer(state: {
+ actionMenuOpened: boolean
+ activeSheet: boolean
+ addMenuOpened: boolean
+ confirmDeletePlaylist: boolean
+ confirmRemoveTrack: boolean
+ playerOpened: boolean
+}): MusicEscapeLayer | null {
+ if (state.confirmRemoveTrack) return 'remove-track-confirmation'
+ if (state.confirmDeletePlaylist) return 'delete-playlist-confirmation'
+ if (state.activeSheet) return 'sheet'
+ if (state.addMenuOpened || state.actionMenuOpened) return 'menu'
+ if (state.playerOpened) return 'player'
+ return null
+}
diff --git a/frontend/src/utils/nui.test.ts b/frontend/src/utils/nui.test.ts
new file mode 100644
index 0000000..39dc5e2
--- /dev/null
+++ b/frontend/src/utils/nui.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { nuiCall } from '@/utils/nui'
+
+describe('nuiCall', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('window', {
+ clearTimeout: globalThis.clearTimeout,
+ location: { search: '' },
+ setTimeout: globalThis.setTimeout,
+ })
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ it('clears the request timeout after a successful callback', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: { value: 1 }, success: true }), {
+ headers: { 'Content-Type': 'application/json' },
+ status: 200,
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({
+ data: { value: 1 },
+ success: true,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'http://localhost:3002/api/test',
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ )
+ })
+
+ it('aborts a callback that never completes', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ reject(new DOMException('Aborted', 'AbortError'))
+ })
+ }),
+ ),
+ )
+
+ const request = nuiCall('never-responds')
+ await vi.advanceTimersByTimeAsync(20_000)
+
+ await expect(request).resolves.toEqual({
+ error: 'request_timeout',
+ success: false,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts
index 22ecce7..eecee7e 100644
--- a/frontend/src/utils/nui.ts
+++ b/frontend/src/utils/nui.ts
@@ -1,4 +1,5 @@
const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone'
+const requestTimeoutMs = 20_000
export type NuiResponse = {
success: boolean
@@ -24,12 +25,15 @@ export async function nuiCall(
undefined,
}
: data
+ const controller = new AbortController()
+ const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs)
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(requestData),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
+ signal: controller.signal,
})
if (!response.ok) {
@@ -41,8 +45,14 @@ export async function nuiCall(
const body = await response.text()
return body ? (JSON.parse(body) as NuiResponse) : { success: true }
} catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error'
+ const message = controller.signal.aborted
+ ? 'request_timeout'
+ : error instanceof Error
+ ? error.message
+ : 'Unknown error'
console.error(`[NUI] ${endpoint} failed:`, error)
return { error: message, success: false }
+ } finally {
+ window.clearTimeout(timeoutId)
}
}
diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts
index 9ddfb58..5084e7d 100644
--- a/frontend/src/utils/preferences.test.ts
+++ b/frontend/src/utils/preferences.test.ts
@@ -85,6 +85,17 @@ describe('preferences', () => {
expect(value.settings.screenBrightness).toBe(10)
})
+ it('keeps the phone above the minimum usable scale', () => {
+ const value = parsePhonePreferences(
+ JSON.stringify({
+ version: 1,
+ settings: { phoneScale: 50 },
+ }),
+ )
+
+ expect(value.settings.phoneScale).toBe(75)
+ })
+
it('preserves safe notification preferences for custom apps', () => {
const appId = 'example-app' as LaunchablePhoneAppId
const value = parsePhonePreferences(
diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts
index 7ef0550..c586705 100644
--- a/frontend/src/utils/preferences.ts
+++ b/frontend/src/utils/preferences.ts
@@ -23,7 +23,7 @@ export const PHONE_FRAME_IDS = [
export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const
export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const
export const WALLPAPER_IDS = ['midnight', 'aurora', 'ember'] as const
-export const PHONE_SCALE_MIN = 50
+export const PHONE_SCALE_MIN = 75
export const PHONE_SCALE_MAX = 150
export const PHONE_SCALE_STEP = 5
@@ -197,6 +197,10 @@ export function ensureAppNotificationPreferences(
}
}
+export function clampPhoneScale(value: number): number {
+ return Math.min(PHONE_SCALE_MAX, Math.max(PHONE_SCALE_MIN, value))
+}
+
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
@@ -249,11 +253,13 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
100,
),
notifications: readNotifications(settings.notifications),
- phoneScale: readNumber(
- settings.phoneScale,
- defaults.phoneScale,
- PHONE_SCALE_MIN,
- PHONE_SCALE_MAX,
+ phoneScale: clampPhoneScale(
+ readNumber(
+ settings.phoneScale,
+ defaults.phoneScale,
+ Number.MIN_SAFE_INTEGER,
+ Number.MAX_SAFE_INTEGER,
+ ),
),
ringtone: readChoice(
settings.ringtone,
diff --git a/frontend/src/utils/widgetLayout.test.ts b/frontend/src/utils/widgetLayout.test.ts
index aac1d6f..38f80a3 100644
--- a/frontend/src/utils/widgetLayout.test.ts
+++ b/frontend/src/utils/widgetLayout.test.ts
@@ -9,6 +9,7 @@ import {
removeWidget,
resizeWidget,
widgetOccupiedCells,
+ widgetKeyboardTarget,
} from '@/utils/widgetLayout'
describe('widget layout', () => {
@@ -47,6 +48,21 @@ describe('widget layout', () => {
expect(next.instances).toHaveLength(layout.instances.length)
})
+ it('provides bounded keyboard targets for each widget size', () => {
+ const layout = createDefaultWidgetLayout()
+ const clock = layout.instances.find(
+ (instance) => instance.id === 'home-clock',
+ )!
+ const music = layout.instances.find(
+ (instance) => instance.id === 'home-music',
+ )!
+
+ expect(widgetKeyboardTarget(clock, 'right')).toEqual({ column: 1, row: 0 })
+ expect(widgetKeyboardTarget(clock, 'up')).toBeNull()
+ expect(widgetKeyboardTarget(music, 'right')).toBeNull()
+ expect(widgetKeyboardTarget(music, 'down')).toEqual({ column: 0, row: 3 })
+ })
+
it('allows a small widget in the center with app cells on both sides', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
diff --git a/frontend/src/utils/widgetLayout.ts b/frontend/src/utils/widgetLayout.ts
index 71182be..5b6dc54 100644
--- a/frontend/src/utils/widgetLayout.ts
+++ b/frontend/src/utils/widgetLayout.ts
@@ -6,6 +6,7 @@ import type {
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
+import type { ReorderDirection } from '@/utils/keyboard'
export const WIDGET_GRID_COLUMNS = 4
export const WIDGET_HOME_ROWS = 5
@@ -296,6 +297,32 @@ export function moveWidget(
return { instances: placed, version: 1 }
}
+export function widgetKeyboardTarget(
+ instance: WidgetInstance,
+ direction: ReorderDirection,
+): { column: number; row: number } | null {
+ const span = WIDGET_SPANS[instance.size]
+ const maximumColumn = WIDGET_GRID_COLUMNS - span.columns
+ const maximumRow = rowsForPage(instance.page) - span.rows
+ const target = {
+ column:
+ instance.column +
+ (direction === 'left' ? -1 : direction === 'right' ? 1 : 0),
+ row:
+ instance.row +
+ (direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
+ }
+ if (
+ target.column < 0 ||
+ target.column > maximumColumn ||
+ target.row < 0 ||
+ target.row > maximumRow
+ ) {
+ return null
+ }
+ return target
+}
+
export function resizeWidget(
layout: WidgetLayout,
id: string,
diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue
index aa02bf4..65331af 100644
--- a/frontend/src/views/SpringboardView.vue
+++ b/frontend/src/views/SpringboardView.vue
@@ -17,13 +17,16 @@ import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import {
deleteHomePage as previewHomePageDelete,
HOME_GRID_PAGE_SIZE,
+ homeKeyboardTarget,
MAX_HOME_GRID_PAGES,
type HomeArea,
} from '@/utils/homeLayout'
+import type { ReorderDirection } from '@/utils/keyboard'
import {
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
WIDGET_GRID_COLUMNS,
+ widgetKeyboardTarget,
widgetOccupiedCells,
} from '@/utils/widgetLayout'
@@ -515,6 +518,14 @@ function stopWidgetDrag(): void {
clearWidgetDragPreview()
}
+function reorderWidget(id: string, direction: ReorderDirection): void {
+ const instance = widgets.layout.instances.find((widget) => widget.id === id)
+ if (!instance) return
+ const target = widgetKeyboardTarget(instance, direction)
+ if (!target) return
+ widgets.move(id, instance.page, target.column, target.row)
+}
+
function removeWidget(id: string): void {
widgets.remove(id)
if (widgetActionId.value === id) widgetActionId.value = null
@@ -620,6 +631,21 @@ function stopHomeDrag(): void {
draggingHomeApp.value = null
}
+function reorderHomeApp(
+ area: HomeArea,
+ sourceIndex: number,
+ direction: ReorderDirection,
+): void {
+ const targetIndex = homeKeyboardTarget(
+ appStore.homeLayout,
+ area,
+ sourceIndex,
+ direction,
+ )
+ if (targetIndex === null) return
+ appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
+}
+
async function addHomePage(): Promise {
if (addingHomePage.value) return
addingHomePage.value = true
@@ -704,6 +730,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
+ @reorder="reorderWidget"
/>
@@ -725,6 +752,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
+ @reorder="reorderWidget"
/>
{
@dragstart="startHomeDrag('dock', appIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
+ @reorder="reorderHomeApp('dock', appIndex, $event)"
/>
document.getElementById('banking-transfer-amount')?.focus())
+ void nextTick(() =>
+ document.getElementById('banking-transfer-amount')?.focus(),
+ )
}
function updateAmount(event: Event): void {
@@ -237,6 +242,7 @@ function focusableSheetElements(): HTMLElement[] {
function handleSheetKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
+ event.stopPropagation()
closeAction()
return
}
@@ -258,6 +264,15 @@ function handleSheetKeydown(event: KeyboardEvent): void {
}
}
+function handleWindowKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Escape' || !action.value || event.defaultPrevented) {
+ return
+ }
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ closeAction()
+}
+
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
@@ -268,9 +283,8 @@ function errorMessage(code: string): string {
async function submitAction(): Promise {
if (!action.value) return
const parsedAmount = Number(amount.value)
- const phoneNumber = action.value === 'transfer'
- ? normalizePhoneNumber(target.value)
- : undefined
+ const phoneNumber =
+ action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
@@ -291,13 +305,17 @@ async function submitAction(): Promise {
action.value = null
}
-onMounted(() => void banking.load())
+onMounted(() => {
+ window.addEventListener('keydown', handleWindowKeydown)
+ void banking.load()
+})
watch(action, async (currentAction) => {
if (currentAction) {
- previousFocus = document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null
+ previousFocus =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null
await nextTick()
document.getElementById('banking-transfer-target')?.focus()
return
@@ -307,6 +325,7 @@ watch(action, async (currentAction) => {
})
onBeforeUnmount(() => {
+ window.removeEventListener('keydown', handleWindowKeydown)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
previousFocus?.focus()
})
@@ -379,12 +398,17 @@ onBeforeUnmount(() => {
{{ formatMoney(banking.overview.bank) }}
- {{ formatMoney(totals.incoming - totals.outgoing, true) }}
+ {{
+ formatMoney(totals.incoming - totals.outgoing, true)
+ }}
{{ phone.t('Apps.banking.recentPeriod') }}
-
+
{
>
-
+
- {{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
+ {{
+ formatMoney(
+ isIncoming(transaction.kind)
+ ? transaction.amount
+ : -transaction.amount,
+ true,
+ )
+ }}
@@ -460,13 +494,18 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.banking.activity') }}
- {{ formatMoney(totals.incoming - totals.outgoing, true) }}
+ {{
+ formatMoney(totals.incoming - totals.outgoing, true)
+ }}
{{ phone.t('Apps.banking.recentPeriod') }}
- {{ phone.t('Apps.banking.incoming') }}
+ {{ phone.t('Apps.banking.incoming') }}
{{ phone.t('Apps.banking.outgoing') }}
@@ -475,14 +514,19 @@ onBeforeUnmount(() => {
:key="day.date.getTime()"
class="banking-chart__day"
role="img"
- :aria-label="phone.t('Apps.banking.chartDaySummary', {
- day: day.label,
- incoming: formatMoney(day.incoming),
- outgoing: formatMoney(day.outgoing),
- })"
+ :aria-label="
+ phone.t('Apps.banking.chartDaySummary', {
+ day: day.label,
+ incoming: formatMoney(day.incoming),
+ outgoing: formatMoney(day.outgoing),
+ })
+ "
>
-
+
{{ day.label }}
@@ -494,7 +538,10 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.banking.allTransactions') }}
-
+
{
:title="transactionTitle(transaction)"
>
-
+
- {{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
+ {{
+ formatMoney(
+ isIncoming(transaction.kind)
+ ? transaction.amount
+ : -transaction.amount,
+ true,
+ )
+ }}
@@ -553,7 +610,7 @@ onBeforeUnmount(() => {
-
+
{
type="number"
:value="amount"
@input="updateAmount"
- @keydown.enter="submitAction"
+ @keydown.enter="handleEnterAction($event, submitAction)"
/>
@@ -776,6 +833,8 @@ onBeforeUnmount(() => {
position: absolute;
top: 50%;
left: 50%;
+ width: 720px;
+ height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
diff --git a/frontend/src/views/apps/MapApp.vue b/frontend/src/views/apps/MapApp.vue
index 5c6cd23..2667c65 100644
--- a/frontend/src/views/apps/MapApp.vue
+++ b/frontend/src/views/apps/MapApp.vue
@@ -36,6 +36,7 @@ import { useMapStore } from '@/stores/map'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MapMarker, MapMarkerColor } from '@/types/map'
+import { handleEnterAction } from '@/utils/keyboard'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
@@ -656,7 +657,7 @@ onBeforeUnmount(() => {
maxlength="40"
outline
@input="updateMarkerLabel"
- @keydown.enter="saveMarker"
+ @keydown.enter="handleEnterAction($event, saveMarker)"
/>
{{
diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue
index d401732..bae7e1f 100644
--- a/frontend/src/views/apps/MessagesApp.vue
+++ b/frontend/src/views/apps/MessagesApp.vue
@@ -54,6 +54,7 @@ import { useMessagesStore } from '@/stores/messages'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
+import { handleEnterAction } from '@/utils/keyboard'
import { sortContactsByMessageRecency } from '@/utils/messages'
import type {
GifSearchResult,
@@ -1496,7 +1497,7 @@ onBeforeUnmount(() => {
:value="draft"
:disabled="sending"
@input="draft = eventValue($event)"
- @keydown.enter.exact.prevent="sendTextMessage"
+ @keydown.enter.exact="handleEnterAction($event, sendTextMessage)"
>
diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue
index 1ac9a67..5de0de9 100644
--- a/frontend/src/views/apps/MusicApp.vue
+++ b/frontend/src/views/apps/MusicApp.vue
@@ -53,6 +53,8 @@ import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
import { easyShareMusicTarget } from '@/utils/easyshare'
+import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
+import { musicEscapeLayer } from '@/utils/musicEscape'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet =
@@ -498,6 +500,31 @@ function updateVolume(event: Event): void {
music.setVolume(Number(eventValue(event)) / 100)
}
+function onKeydown(event: KeyboardEvent): void {
+ const layer = musicEscapeLayer({
+ actionMenuOpened: actionMenuOpened.value,
+ activeSheet: Boolean(activeSheet.value),
+ addMenuOpened: addMenuOpened.value,
+ confirmDeletePlaylist: confirmDeletePlaylist.value,
+ confirmRemoveTrack: confirmRemoveTrack.value,
+ playerOpened: playerOpened.value,
+ })
+ if (!layer) return
+ if (!consumeEscape(event)) return
+
+ if (layer === 'remove-track-confirmation') {
+ cancelRemoveTrack()
+ } else if (layer === 'delete-playlist-confirmation') {
+ confirmDeletePlaylist.value = false
+ } else if (layer === 'sheet') {
+ closeSheet()
+ } else if (layer === 'menu') {
+ dismissMenus()
+ } else if (layer === 'player') {
+ playerOpened.value = false
+ }
+}
+
watch(
() => music.playlists,
() => {
@@ -510,6 +537,7 @@ watch(
)
onMounted(async () => {
+ window.addEventListener('keydown', onKeydown, true)
await music.load()
const target = easyShareMusicTarget(
route.query.easyShareKind,
@@ -531,6 +559,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
+ window.removeEventListener('keydown', onKeydown, true)
})
@@ -1061,12 +1090,12 @@ onBeforeUnmount(() => {
-
-
+
-
-
+
{
.music-form-sheet,
.music-player-sheet {
--music-accent: #fa2d48;
+ display: contents;
color: var(--music-label);
}
@@ -1985,7 +2017,7 @@ onBeforeUnmount(() => {
color: #ff453a !important;
}
-.music-player-sheet {
+.music-player-sheet :deep(.k-sheet) {
background: rgb(22 22 25 / 96%) !important;
}
diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue
index 2a6c55b..a4c600e 100644
--- a/frontend/src/views/apps/NotesApp.vue
+++ b/frontend/src/views/apps/NotesApp.vue
@@ -50,7 +50,8 @@ const deleteActionColors = {
textMaterial: 'text-red-500',
}
const noteBodyStyle: CSSProperties = {
- height: 'calc(100cqh - 210px)',
+ height: '617px',
+ maxHeight: 'calc(100% - 210px)',
resize: 'none',
}
const currentNote = computed(() =>
diff --git a/frontend/src/views/apps/NumberMergeApp.vue b/frontend/src/views/apps/NumberMergeApp.vue
index c1d6cbf..897c649 100644
--- a/frontend/src/views/apps/NumberMergeApp.vue
+++ b/frontend/src/views/apps/NumberMergeApp.vue
@@ -348,6 +348,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 10px;
+}
+
+.number-merge-header > div {
+ min-width: 0;
}
.number-merge-header span {
@@ -357,6 +362,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
+ overflow-wrap: anywhere;
}
.number-merge-header h1 {
@@ -364,6 +370,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-size: 28px;
line-height: 1;
letter-spacing: -1px;
+ overflow-wrap: anywhere;
}
.number-merge-header button,
@@ -383,11 +390,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-menu {
height: calc(100% - 55px);
+ min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
gap: 13px;
+ overflow-y: auto;
+ padding: 12px 0;
text-align: center;
}
@@ -401,6 +411,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
border-radius: 29px;
background: #7e5147;
box-shadow: 0 17px 30px rgb(93 48 36 / 20%);
+ flex: 0 0 auto;
+ margin-top: auto;
transform: rotate(-2deg);
}
@@ -418,8 +430,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-hero__tile--4 { color: #fff7e4; background: #e96c2c; }
.number-merge-hero__tile--8 { color: #fff7e4; background: #713c31; }
-.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; }
-.number-merge-menu__intro p { max-width: 300px; margin: 6px 0 0; color: #8b675b; font-size: 14px; line-height: 1.45; }
+.number-merge-menu__intro { width: 100%; }
+.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; overflow-wrap: anywhere; }
+.number-merge-menu__intro p { max-width: 300px; margin: 6px auto 0; color: #8b675b; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
.number-merge-records {
width: 100%;
@@ -429,6 +442,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-records div {
+ min-width: 0;
display: grid;
gap: 1px;
padding: 8px;
@@ -443,9 +457,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
+ overflow-wrap: anywhere;
}
-.number-merge-records strong { font-size: 20px; }
+.number-merge-records strong { min-width: 0; overflow-wrap: anywhere; font-size: 20px; }
.number-merge-menu__actions { width: 100%; display: grid; gap: 7px; }
@@ -453,11 +468,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-secondary,
.number-merge-danger {
min-height: 46px;
- padding: 0 18px;
+ padding: 10px 18px;
border-radius: 14px;
font-size: 15px;
font-weight: 850;
cursor: pointer;
+ overflow-wrap: anywhere;
}
.number-merge-primary {
@@ -474,14 +490,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-how-to {
+ width: 100%;
+ flex: 0 0 auto;
+ margin-bottom: auto;
padding: 9px 15px;
border-radius: 14px;
color: #795549;
background: rgb(255 255 255 / 28%);
}
-.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; }
-.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; }
+.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; overflow-wrap: anywhere; }
+.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
.number-merge-how-to div {
margin: 7px 0 4px;
color: #a7472d;
@@ -489,7 +508,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 900;
letter-spacing: -0.2px;
}
-.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
+.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; overflow-wrap: anywhere; }
.number-merge-game {
position: absolute;
@@ -629,7 +648,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
gap: 8px;
padding: 22px;
border-radius: 20px;
@@ -637,12 +656,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
backdrop-filter: blur(5px);
color: #fff7ea;
text-align: center;
+ overflow-y: auto;
}
-.number-merge-overlay > span { color: #ffc75b; font-size: 29px; font-weight: 900; }
-.number-merge-overlay h2 { margin: 0; font-size: 25px; }
-.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; }
-.number-merge-overlay button { min-width: 160px; }
+.number-merge-overlay > span { margin-top: auto; color: #ffc75b; font-size: 29px; font-weight: 900; }
+.number-merge-overlay h2 { margin: 0; font-size: 25px; overflow-wrap: anywhere; }
+.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; }
+.number-merge-overlay button { min-width: min(160px, 100%); }
+.number-merge-overlay .number-merge-link { margin-bottom: auto; }
.number-merge-overlay .number-merge-secondary { color: #fff1df; background: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 13%); }
.number-merge-link {
@@ -677,6 +698,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
inset: 0;
display: grid;
place-items: center;
+ min-height: 0;
+ overflow-y: auto;
padding: 30px;
background: rgb(54 29 25 / 54%);
backdrop-filter: blur(6px);
@@ -684,6 +707,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-confirm > div {
width: 100%;
+ max-height: calc(100% - 32px);
+ overflow-y: auto;
display: grid;
gap: 9px;
padding: 21px;
@@ -694,8 +719,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
text-align: center;
}
-.number-merge-confirm h2 { margin: 0; font-size: 21px; }
-.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; }
+.number-merge-confirm h2 { margin: 0; font-size: 21px; overflow-wrap: anywhere; }
+.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; }
.number-merge-danger { border: 0; color: #fff; background: #b64f39; }
button:active { transform: scale(0.97); }
diff --git a/frontend/src/views/apps/PhoneApp.vue b/frontend/src/views/apps/PhoneApp.vue
index e0461be..49f69e1 100644
--- a/frontend/src/views/apps/PhoneApp.vue
+++ b/frontend/src/views/apps/PhoneApp.vue
@@ -3729,7 +3729,7 @@ onBeforeUnmount(() => {
box-shadow: inset 0 0 0 1px rgba(142, 142, 147, 0.18);
}
- .phone-recent-row:has(button:hover),
+ .phone-recent-row:hover,
.phone-my-card:hover,
.phone-contact-row:hover {
border-radius: 14px;
diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue
index dc7a6d6..96070a0 100644
--- a/frontend/src/views/apps/PicstagramApp.vue
+++ b/frontend/src/views/apps/PicstagramApp.vue
@@ -2852,4 +2852,10 @@ onBeforeUnmount(() => {
transform: scale(1.28);
}
}
+
+@supports not (color: color-mix(in srgb, white, black)) {
+ .ps-activity--unread {
+ background: rgb(10 132 255 / 10%);
+ }
+}
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index 88b5d49..b9549a4 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -4065,7 +4065,6 @@ function easyShareHistoryForScenario(testScenario) {
}
app.post('/api/:endpoint', (request, response) => {
- console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
const testScenario = String(request.body._testScenario ?? '')
if (lifecycleEndpoints.has(endpoint)) {
@@ -7186,7 +7185,7 @@ app.post('/api/:endpoint', (request, response) => {
: messageType === 'gif'
? 'image/gif'
: messageType === 'video'
- ? 'video/mp4'
+ ? 'video/webm'
: null,
media_payload:
messageType === 'voice'
diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua
index 98ced28..0abfc1a 100644
--- a/sky_phone/config/media.lua
+++ b/sky_phone/config/media.lua
@@ -1,11 +1,11 @@
Config.Media = {
- GiphyApiKey = "",
+ GiphyApiKeyConvar = "sky_phone_giphy_api_key",
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
- ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access.
+ ApiKeyConvar = "sky_phone_fivemanage_api_key",
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
diff --git a/sky_phone/config/payphones.lua b/sky_phone/config/payphones.lua
new file mode 100644
index 0000000..e2a3ecd
--- /dev/null
+++ b/sky_phone/config/payphones.lua
@@ -0,0 +1,248 @@
+-- Server-owned vanilla payphone positions used for authoritative proximity checks.
+-- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895.
+-- Add custom-map booths here; their model must also be listed in Config.Payphones.Props.
+Config.Payphones.Locations = {
+ { model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } },
+ { model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } },
+ { model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } },
+ { model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } },
+ { model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } },
+ { model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } },
+ { model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } },
+ { model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } },
+ { model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } },
+ { model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } },
+ { model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } },
+ { model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } },
+ { model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } },
+ { model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } },
+ { model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } },
+ { model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } },
+ { model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } },
+ { model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } },
+ { model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } },
+ { model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } },
+ { model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } },
+ { model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } },
+ { model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } },
+ { model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } },
+ { model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } },
+ { model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } },
+ { model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } },
+ { model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } },
+ { model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } },
+ { model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } },
+ { model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } },
+ { model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } },
+ { model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } },
+ { model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } },
+ { model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } },
+ { model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } },
+ { model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } },
+ { model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } },
+ { model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } },
+ { model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } },
+ { model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } },
+ { model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } },
+ { model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } },
+ { model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } },
+ { model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } },
+ { model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } },
+ { model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } },
+ { model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } },
+ { model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } },
+ { model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } },
+ { model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } },
+ { model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } },
+ { model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } },
+ { model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } },
+ { model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } },
+ { model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } },
+ { model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } },
+ { model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } },
+ { model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } },
+ { model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } },
+ { model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } },
+ { model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } },
+ { model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } },
+ { model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } },
+ { model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } },
+ { model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } },
+ { model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } },
+ { model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } },
+ { model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } },
+ { model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } },
+ { model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } },
+ { model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } },
+ { model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } },
+ { model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } },
+ { model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } },
+ { model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } },
+ { model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } },
+ { model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } },
+ { model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } },
+ { model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } },
+ { model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } },
+ { model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } },
+ { model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } },
+ { model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } },
+ { model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } },
+ { model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } },
+ { model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } },
+ { model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } },
+ { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } },
+ { model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } },
+ { model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } },
+ { model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } },
+ { model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } },
+ { model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } },
+ { model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } },
+ { model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } },
+ { model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } },
+ { model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } },
+ { model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } },
+ { model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } },
+ { model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } },
+ { model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } },
+ { model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } },
+ { model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } },
+ { model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } },
+ { model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } },
+ { model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } },
+ { model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } },
+ { model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } },
+ { model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } },
+ { model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } },
+ { model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } },
+ { model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } },
+ { model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } },
+ { model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } },
+ { model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } },
+ { model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } },
+ { model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } },
+ { model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } },
+ { model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } },
+ { model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } },
+ { model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } },
+ { model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } },
+ { model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } },
+ { model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } },
+ { model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } },
+ { model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } },
+ { model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } },
+ { model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } },
+ { model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } },
+ { model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } },
+ { model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } },
+ { model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } },
+ { model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } },
+ { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } },
+ { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } },
+ { model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } },
+ { model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } },
+ { model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } },
+ { model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } },
+ { model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } },
+ { model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } },
+ { model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } },
+ { model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } },
+ { model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } },
+ { model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } },
+ { model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } },
+ { model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } },
+ { model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } },
+ { model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } },
+ { model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } },
+ { model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } },
+ { model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } },
+ { model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } },
+ { model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } },
+ { model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } },
+ { model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } },
+ { model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } },
+ { model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } },
+ { model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } },
+ { model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } },
+ { model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } },
+ { model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } },
+ { model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } },
+ { model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } },
+ { model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } },
+ { model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } },
+ { model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } },
+ { model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } },
+ { model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } },
+ { model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } },
+ { model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } },
+ { model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } },
+ { model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } },
+ { model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } },
+ { model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } },
+ { model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } },
+ { model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } },
+ { model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } },
+ { model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } },
+ { model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } },
+ { model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } },
+ { model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } },
+ { model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } },
+ { model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } },
+ { model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } },
+ { model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } },
+ { model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } },
+ { model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } },
+ { model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } },
+ { model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } },
+ { model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } },
+ { model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } },
+ { model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } },
+ { model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } },
+ { model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } },
+ { model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } },
+ { model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } },
+ { model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } },
+ { model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } },
+ { model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } },
+ { model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } },
+ { model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } },
+ { model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } },
+ { model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } },
+ { model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } },
+ { model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } },
+ { model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } },
+ { model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } },
+ { model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } },
+ { model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } },
+ { model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } },
+ { model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } },
+ { model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } },
+ { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } },
+ { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } },
+ { model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } },
+ { model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } },
+ { model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } },
+ { model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } },
+}
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index ab0e6f8..e5f67e7 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -30,6 +30,7 @@ client_scripts {
'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua',
'source/client/animations.lua',
+ 'source/client/focus.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/client/skyride.lua',
@@ -46,6 +47,7 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
+ 'config/payphones.lua',
'config/companies.lua',
'config/media.lua',
'config/music.lua',
@@ -66,6 +68,7 @@ server_scripts {
'source/server/companies.lua',
'source/server/custom_app_storage.lua',
'source/server/sim.lua',
+ 'source/server/payphones.lua',
'source/server/calls.lua',
'source/server/media.lua',
'source/server/messages.lua',
diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua
index 1cfa566..581d9b0 100644
--- a/sky_phone/source/client/camera.lua
+++ b/sky_phone/source/client/camera.lua
@@ -6,6 +6,10 @@ local ultrawide_fov_multiplier = 2.0
local front_camera_distance = 0.75
local front_camera_height = 0.05
local front_camera_target_height = 0.03
+local unfocused_camera_controls = {
+ 1, -- INPUT_LOOK_LR
+ 2, -- INPUT_LOOK_UD
+}
local camera_state = {
active = false,
enforcing = false,
@@ -16,6 +20,7 @@ local camera_state = {
front_camera_handle = nil,
landscape = false,
ultrawide_camera_handle = nil,
+ applied_nui_focus = true,
nui_focused = true,
previous_ped_view = nil,
previous_radar_hidden = nil,
@@ -145,27 +150,32 @@ local function restore_camera_view()
end
end
-local function set_camera_focus(focused)
- if camera_state.nui_focused == focused then
- return
+local function apply_unfocused_camera_controls()
+ DisableAllControlActions(0)
+ for _, control in ipairs(unfocused_camera_controls) do
+ EnableControlAction(0, control, true)
end
- camera_state.nui_focused = focused
- if focused then
- SetNuiFocus(true, true)
- SetNuiFocusKeepInput(false)
- SendNUIMessage({ type = "camera:focus", data = { focused = true } })
- return
- end
- SetNuiFocus(false, false)
- SetNuiFocusKeepInput(true)
- SendNUIMessage({ type = "camera:focus", data = { focused = false } })
+ DisablePlayerFiring(PlayerId(), true)
+end
+
+local function update_camera_focus_claim()
+ TriggerEvent("sky_phone:client:setCameraFocus", {
+ active = camera_state.active,
+ nuiFocused = camera_state.nui_focused,
+ })
+end
+
+local set_camera_focus
+
+local function watch_unfocused_camera_controls()
if camera_state.focus_watcher then
return
end
camera_state.focus_watcher = true
CreateThread(function()
- while camera_state.active and not camera_state.nui_focused do
- if IsControlJustReleased(0, 22) then
+ while camera_state.active and not camera_state.applied_nui_focus do
+ apply_unfocused_camera_controls()
+ if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
break
end
@@ -175,6 +185,28 @@ local function set_camera_focus(focused)
end)
end
+set_camera_focus = function(focused)
+ camera_state.nui_focused = focused
+ update_camera_focus_claim()
+end
+
+AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
+ if type(data) ~= "table"
+ or type(data.active) ~= "boolean"
+ or type(data.focused) ~= "boolean"
+ or type(data.gameInput) ~= "boolean"
+ then
+ return
+ end
+ if camera_state.applied_nui_focus ~= data.focused then
+ camera_state.applied_nui_focus = data.focused
+ SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
+ end
+ if data.active and data.gameInput then
+ watch_unfocused_camera_controls()
+ end
+end)
+
local function set_camera_active(active)
if camera_state.active == active then
return
@@ -251,11 +283,8 @@ local function set_camera_active(active)
clear_front_camera()
clear_ultrawide_camera()
restore_camera_view()
- if not camera_state.nui_focused then
- camera_state.nui_focused = true
- SetNuiFocusKeepInput(false)
- SetNuiFocus(true, true)
- end
+ camera_state.nui_focused = true
+ update_camera_focus_claim()
TriggerEvent("sky_phone:animation:camera", {
active = false,
front = false,
@@ -325,58 +354,102 @@ local function set_camera_zoom(zoom)
end
RegisterNUICallback("camera:setActive", function(data, cb)
- set_camera_active(data and data.active == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_camera_active(data.active == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFocus", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if camera_state.active then
- set_camera_focus(data and data.focused == true)
+ set_camera_focus(data.focused == true)
end
cb({ success = true })
end)
RegisterNUICallback("camera:setFlash", function(data, cb)
- set_flash_enabled(data and data.enabled == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_flash_enabled(data.enabled == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFacing", function(data, cb)
- set_front_camera(data and data.front == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_front_camera(data.front == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setOrientation", function(data, cb)
- set_camera_landscape(data and data.landscape == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_camera_landscape(data.landscape == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setZoom", function(data, cb)
- cb({ success = set_camera_zoom(tonumber(data and data.zoom)) })
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ cb({ success = set_camera_zoom(tonumber(data.zoom)) })
end)
RegisterNUICallback("media:requestUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:request-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:request-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:completeUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:complete-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:complete-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:cancelUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:cancel-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:cancel-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:failUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:fail-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:fail-upload", data)
cb({ success = true })
end)
RegisterNUICallback("gallery:delete", function(data, cb)
- TriggerServerEvent("sky_phone:media:delete", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:delete", data)
cb({ success = true })
end)
@@ -401,6 +474,5 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
set_flash_enabled(false)
set_camera_active(false)
- SetNuiFocusKeepInput(false)
end
end)
diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua
index 5ecf4eb..4767466 100644
--- a/sky_phone/source/client/crewlink.lua
+++ b/sky_phone/source/client/crewlink.lua
@@ -16,6 +16,10 @@ local function draw_overhead_label(coords, username, role)
end
RegisterNUICallback("crewlink:live", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:live", data)
overhead_members = result and result.success and result.data and result.data.overheadMembers or {}
overhead_expires_at = GetGameTimer() + Config.CrewLink.OverheadRefreshMilliseconds * 2
diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua
new file mode 100644
index 0000000..8252694
--- /dev/null
+++ b/sky_phone/source/client/focus.lua
@@ -0,0 +1,21 @@
+SkyPhoneFocus = {}
+
+function SkyPhoneFocus.Resolve(state)
+ if state.activity_suspended then
+ return { focused = false, keep_input = false }
+ end
+ if state.call_focus then
+ return { focused = true, keep_input = false }
+ end
+ if state.camera_active and not state.camera_nui_focused then
+ return { focused = false, keep_input = true }
+ end
+ return {
+ focused = state.is_open
+ or state.notification_focus
+ or state.payphone_focus
+ or state.sim_picker_open
+ or (state.camera_active and state.camera_nui_focused),
+ keep_input = false,
+ }
+end
diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua
index 6a40adf..cee1d5d 100644
--- a/sky_phone/source/client/garage.lua
+++ b/sky_phone/source/client/garage.lua
@@ -441,6 +441,10 @@ local function run_valet_delivery(order)
end
RegisterNUICallback("garage:valet-request", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if current_valet then
cb({ success = false, error = "valet_active" })
return
diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua
index d54d217..93f3b5b 100644
--- a/sky_phone/source/client/housing.lua
+++ b/sky_phone/source/client/housing.lua
@@ -11,18 +11,18 @@ local function server_prepare(action, data)
end
local function suspend_phone()
- SetNuiFocus(false, false)
+ TriggerEvent("sky_phone:client:setSuspended", true)
TriggerEvent("sky_phone:animation:phone", false)
SendNUIMessage({ type = "app:suspend" })
end
local function resume_phone()
SendNUIMessage({ type = "app:resume" })
- SetNuiFocus(true, true)
+ TriggerEvent("sky_phone:client:setSuspended", false)
TriggerEvent("sky_phone:animation:phone", true)
end
-local function stop_camera()
+local function stop_camera(resume)
local state = camera_state
if not state then
return
@@ -37,7 +37,9 @@ local function stop_camera()
if DoesEntityExist(state.ped) then
FreezeEntityPosition(state.ped, state.frozen)
end
- resume_phone()
+ if resume ~= false then
+ resume_phone()
+ end
end
local function camera_number(value, fallback)
@@ -105,13 +107,17 @@ local function run_camera(provider_name, camera_data)
local maximum_left = camera_number(camera_data.maximumLeft)
local maximum_right = camera_number(camera_data.maximumRight)
local night_vision = camera_state.night_vision
+ local resume_after_camera = false
+ local close_phone_after_camera = false
while camera_state and camera_state.camera == camera do
Wait(0)
DisableAllControlActions(0)
- if IsDisabledControlJustPressed(0, config.ExitControl)
- or IsPedDeadOrDying(ped, true)
- or GetResourceState(provider_name) ~= "started"
- then
+ if IsDisabledControlJustPressed(0, config.ExitControl) then
+ resume_after_camera = true
+ break
+ end
+ if IsPedDeadOrDying(ped, true) or GetResourceState(provider_name) ~= "started" then
+ close_phone_after_camera = true
break
end
@@ -147,12 +153,29 @@ local function run_camera(provider_name, camera_data)
SetNightvision(night_vision)
end
end
- stop_camera()
+ stop_camera(resume_after_camera)
+ if close_phone_after_camera then
+ TriggerEvent("sky_phone:client:forceClose")
+ end
end
-RegisterNUICallback("housing:overview", function(_, cb)
+RegisterNetEvent("sky_phone:device:invalidated", function()
+ stop_camera(false)
+end)
+
+RegisterNUICallback("housing:overview", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
+end)
+
+AddEventHandler("sky_phone:client:nuiReady", function()
+ if camera_state then
+ suspend_phone()
+ end
end)
RegisterNUICallback("housing:key-candidates", function(data, cb)
@@ -161,7 +184,7 @@ RegisterNUICallback("housing:key-candidates", function(data, cb)
return
end
local result = server_prepare("key_candidates", data)
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("housing:command", function(data, cb)
@@ -182,8 +205,8 @@ RegisterNUICallback("housing:command", function(data, cb)
end
local prepared = server_prepare(data.action, data)
- if not prepared or not prepared.success or type(prepared.data) ~= "table" then
- cb(prepared or { success = false, error = "request_failed" })
+ if type(prepared) ~= "table" or not prepared.success or type(prepared.data) ~= "table" then
+ cb(type(prepared) == "table" and prepared or { success = false, error = "request_failed" })
return
end
if data.action == "set_waypoint" then
@@ -214,6 +237,6 @@ end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() and camera_state then
- stop_camera()
+ stop_camera(false)
end
end)
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 8c97ab3..2e88d41 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -1,9 +1,17 @@
local is_open = false
local open_requested = false
local notification_focus = false
+local call_focus = false
+local payphone_focus = false
+local camera_active = false
+local camera_nui_focused = true
local device_payload = nil
local sim_picker_open = false
+local sim_picker_payload = nil
+local active_call_payload = nil
local call_channel = 0
+local nui_generation = 0
+local activity_suspended = false
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
@@ -256,6 +264,46 @@ local function get_locale()
return Locales[Config.Bridge.Locale] or Locales["en"]
end
+local function update_nui_focus()
+ local focus = SkyPhoneFocus.Resolve({
+ activity_suspended = activity_suspended,
+ call_focus = call_focus,
+ camera_active = camera_active,
+ camera_nui_focused = camera_nui_focused,
+ is_open = is_open,
+ notification_focus = notification_focus,
+ payphone_focus = payphone_focus,
+ sim_picker_open = sim_picker_open,
+ })
+ SetNuiFocus(focus.focused, focus.focused)
+ SetNuiFocusKeepInput(focus.keep_input)
+ TriggerEvent("sky_phone:client:cameraFocusApplied", {
+ active = camera_active,
+ focused = focus.focused,
+ gameInput = focus.keep_input,
+ })
+end
+
+AddEventHandler("sky_phone:client:setSuspended", function(suspended)
+ activity_suspended = suspended == true
+ update_nui_focus()
+end)
+
+AddEventHandler("sky_phone:client:setPayphoneFocus", function(focused)
+ payphone_focus = focused == true
+ update_nui_focus()
+end)
+
+AddEventHandler("sky_phone:client:setCameraFocus", function(data)
+ if type(data) ~= "table" or type(data.active) ~= "boolean" or type(data.nuiFocused) ~= "boolean" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid camera focus claim.")
+ return
+ end
+ camera_active = data.active
+ camera_nui_focused = data.nuiFocused
+ update_nui_focus()
+end)
+
local function send_open_message()
if not device_payload then
return
@@ -279,21 +327,31 @@ local function open_phone()
send_open_message()
end
-local function close_phone()
+local function close_phone(close_device_session)
+ local was_requested = open_requested
+ local was_open = is_open
open_requested = false
+ call_focus = false
+ activity_suspended = false
TriggerEvent("sky_phone:animation:phone", false)
- if not is_open then
- return
- end
-
is_open = false
- SkyPhoneApps.SetPhoneOpen(false)
- TriggerEvent("sky_phone:nuiClosed")
- SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
- SendNUIMessage({ type = "app:close" })
- Bridge.Callbacks.Trigger("sky_phone:device:close", {})
+ if was_open then
+ SkyPhoneApps.SetPhoneOpen(false)
+ TriggerEvent("sky_phone:nuiClosed")
+ end
+ update_nui_focus()
+ if was_requested or was_open then
+ SendNUIMessage({ type = "app:close" })
+ if close_device_session ~= false then
+ Bridge.Callbacks.Trigger("sky_phone:device:close", {})
+ end
+ end
end
+AddEventHandler("sky_phone:client:forceClose", function()
+ close_phone()
+end)
+
local function leave_call_voice()
if call_channel == 0 then
return
@@ -337,24 +395,58 @@ RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000)
end)
-RegisterNUICallback("ui:ready", function(_, cb)
+RegisterNUICallback("ui:ready", function(data, cb)
+ if type(data) ~= "table" or data.protocolVersion ~= 1 then
+ cb({ success = false, error = "unsupported_protocol" })
+ return
+
+ end
+ nui_generation = nui_generation + 1
+ -- Browser state is recreated on a CEF reload. A notification focus claim
+ -- cannot survive unless its notification is replayed as part of this handshake.
+ notification_focus = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
- TriggerEvent("sky_phone:client:nuiReady")
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
+ if active_call_payload then
+ SendNUIMessage({ type = "call:state", data = active_call_payload })
+ end
+ if sim_picker_open and sim_picker_payload then
+ SendNUIMessage({ type = "sim:picker", data = sim_picker_payload })
+ else
+ SendNUIMessage({ type = "sim:picker-close" })
+ end
- cb({ success = true })
+ update_nui_focus()
+ TriggerEvent("sky_phone:client:nuiReady", {
+ generation = nui_generation,
+ protocolVersion = 1,
+ })
+
+ cb({
+ success = true,
+ data = {
+ generation = nui_generation,
+ protocolVersion = 1,
+ },
+ })
end)
-RegisterNUICallback("ui:opened", function(_, cb)
+RegisterNUICallback("ui:opened", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
+ SendNUIMessage({ type = "app:close" })
+ update_nui_focus()
cb({ success = false, error = "open_not_requested" })
return
end
@@ -362,29 +454,49 @@ RegisterNUICallback("ui:opened", function(_, cb)
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
notification_focus = false
- SetNuiFocus(true, true)
+ call_focus = false
+ update_nui_focus()
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
-RegisterNUICallback("close", function(_, cb)
+RegisterNUICallback("close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
close_phone()
cb({ success = true })
end)
RegisterNUICallback("notification:focus", function(data, cb)
+ if type(data) ~= "table" or type(data.active) ~= "boolean" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
notification_focus = data.active == true and not is_open
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
+ update_nui_focus()
cb({ success = true })
end)
-RegisterNUICallback("sim:picker-close", function(_, cb)
+RegisterNUICallback("sim:picker-close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
sim_picker_open = false
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
- cb({ success = true })
+ sim_picker_payload = nil
+ update_nui_focus()
+ SendNUIMessage({ type = "sim:picker-close" })
+ local result = Bridge.Callbacks.Trigger("sky_phone:sim:picker-close", {})
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
-RegisterNUICallback("map:getPlayerCoords", function(_, cb)
+RegisterNUICallback("map:getPlayerCoords", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local coords = GetEntityCoords(PlayerPedId())
cb({
success = true,
@@ -439,7 +551,11 @@ local function weather_region(coords)
return "los_santos"
end
-RegisterNUICallback("weather:get", function(_, cb)
+RegisterNUICallback("weather:get", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
local next_weather_hash = GetNextWeatherTypeHashName()
@@ -479,9 +595,13 @@ local function garage_vehicle_kind(model_hash, fallback)
end
RegisterNUICallback("garage:vehicles", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
- if not result or not result.success or type(result.data) ~= "table" then
- cb(result or { success = false, error = "request_failed" })
+ if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
return
end
for _, vehicle in ipairs(result.data.vehicles or {}) do
@@ -505,8 +625,12 @@ end)
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
- if result then
+ if type(result) == "table" then
cb(result)
return
end
@@ -516,6 +640,10 @@ for _, callback_name in ipairs(server_callbacks) do
end
RegisterNetEvent("sky_phone:device:open", function(data)
+ if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
+ return
+ end
Bridge.Debug(
"debug",
"[sky_phone] Client received device open for IMEI %s account_linked=%s.",
@@ -525,19 +653,27 @@ RegisterNetEvent("sky_phone:device:open", function(data)
)
device_payload = data
open_requested = true
+ if is_open then
+ SkyPhoneApps.SendCatalog()
+ SendNUIMessage({ type = "device:updated", data = data })
+ return
+ end
open_phone()
end)
RegisterNetEvent("sky_phone:device:updated", function(data)
+ if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
+ return
+ end
device_payload = data
SendNUIMessage({ type = "device:updated", data = data })
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
- open_requested = false
- device_payload = nil
TriggerEvent("sky_phone:animation:reset")
- close_phone()
+ close_phone(false)
+ device_payload = nil
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
@@ -669,14 +805,20 @@ RegisterNetEvent("sky_phone:flare:message", function(data)
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
+ if type(data) ~= "table" or type(data.number) ~= "string" or type(data.choices) ~= "table" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid SIM picker data.")
+ return
+ end
sim_picker_open = true
- SetNuiFocus(true, true)
+ sim_picker_payload = data
+ update_nui_focus()
SendNUIMessage({ type = "sim:picker", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker-close", function()
sim_picker_open = false
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
+ sim_picker_payload = nil
+ update_nui_focus()
SendNUIMessage({ type = "sim:picker-close" })
end)
@@ -752,13 +894,35 @@ RegisterNetEvent("sky_phone:darkchat:new", function(data)
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
- notification_focus = true
- SetNuiFocus(true, true)
+ if type(data) ~= "table" or type(data.id) ~= "string" or data.state ~= "ringing" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid incoming call data.")
+ return
+ end
+ active_call_payload = data
+ call_focus = true
+ update_nui_focus()
TriggerEvent("sky_phone:animation:call", data)
SendNUIMessage({ type = "call:incoming", data = data })
end)
RegisterNetEvent("sky_phone:call:state", function(data)
+ if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid call state data.")
+ return
+ end
+ if data.state == "ringing" or data.state == "connected" then
+ active_call_payload = data
+ end
+ if data.state ~= "ringing" then
+ local focus_changed = call_focus
+ call_focus = false
+ if focus_changed then
+ update_nui_focus()
+ end
+ end
+ if data.state ~= "ringing" and data.state ~= "connected" then
+ active_call_payload = nil
+ end
if data.state == "connected" and data.channel then
if not join_call_voice(data.channel) then
TriggerEvent("sky_phone:device:error", "voice_unavailable")
@@ -784,9 +948,19 @@ AddEventHandler("onResourceStop", function(resource_name)
return
end
- if is_open or notification_focus then
- SetNuiFocus(false, false)
- end
+ is_open = false
+ open_requested = false
+ notification_focus = false
+ call_focus = false
+ payphone_focus = false
+ camera_active = false
+ camera_nui_focused = true
+ sim_picker_open = false
+ sim_picker_payload = nil
+ active_call_payload = nil
+ activity_suspended = false
+ SetNuiFocusKeepInput(false)
+ SetNuiFocus(false, false)
TriggerEvent("sky_phone:animation:reset")
leave_call_voice()
diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua
index d3fb3b4..d59b569 100644
--- a/sky_phone/source/client/payphones.lua
+++ b/sky_phone/source/client/payphones.lua
@@ -6,6 +6,7 @@ local active_call_state = nil
local active_call_number = nil
local active_call_elapsed_seconds = 0
local active_call_elapsed_updated_at = 0
+local active_call_payload = nil
local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
@@ -360,11 +361,20 @@ local function booth_payload(booth)
}
end
+local function payphone_open_payload()
+ return {
+ currency = Config.Payphones.Currency,
+ maxNumberLength = Config.Sim.NumberLength,
+ pricePerSecond = Config.Payphones.PricePerSecond,
+ locales = get_locale().Nui.Payphone,
+ }
+end
+
local function close_payphone()
local was_open = payphone_open
payphone_open = false
if was_open then
- SetNuiFocus(false, false)
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
SendNUIMessage({ type = "payphone:close" })
end
if not active_call_id then
@@ -418,6 +428,7 @@ local function apply_active_call_state(data)
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
end
+ active_call_payload = data
end
local function clear_active_call_state()
@@ -426,6 +437,7 @@ local function clear_active_call_state()
active_call_number = nil
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
+ active_call_payload = nil
hangup_requested = false
end
@@ -435,48 +447,51 @@ local function open_payphone(booth)
end
active_booth = booth
payphone_open = true
- SetNuiFocus(true, true)
+ TriggerEvent("sky_phone:client:setPayphoneFocus", true)
SendNUIMessage({
type = "payphone:open",
- data = {
- currency = Config.Payphones.Currency,
- maxNumberLength = Config.Sim.NumberLength,
- pricePerSecond = Config.Payphones.PricePerSecond,
- locales = get_locale().Nui.Payphone,
- },
+ data = payphone_open_payload(),
})
end
RegisterNUICallback("payphone:dial", function(data, cb)
- if not payphone_open or not active_booth or active_call_id then
+ if type(data) ~= "table" or not payphone_open or not active_booth or active_call_id then
cb({ success = false, error = "invalid_request" })
return
end
local payload = booth_payload(active_booth)
- payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil
+ payload.phoneNumber = data.phoneNumber
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload)
- local call_started = result and result.success and result.data
+ local call_started = type(result) == "table" and result.success and type(result.data) == "table"
and (result.data.state == "ringing" or result.data.state == "connected")
if call_started then
apply_active_call_state(result.data)
end
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
if call_started then
close_payphone()
start_call_visuals()
end
end)
-RegisterNUICallback("payphone:hangup", function(_, cb)
+RegisterNUICallback("payphone:hangup", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if not active_call_id then
cb({ success = false, error = "call_not_found" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
-RegisterNUICallback("payphone:close", function(_, cb)
+RegisterNUICallback("payphone:close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if active_call_id then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
@@ -485,7 +500,7 @@ RegisterNUICallback("payphone:close", function(_, cb)
end)
RegisterNetEvent("sky_phone:payphone:state", function(data)
- if type(data) ~= "table" then
+ if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
return
end
if data.state == "ringing" or data.state == "connected" then
@@ -506,6 +521,24 @@ RegisterNetEvent("sky_phone:payphone:state", function(data)
SendNUIMessage({ type = "payphone:state", data = data })
end)
+AddEventHandler("sky_phone:client:nuiReady", function()
+ if payphone_open then
+ TriggerEvent("sky_phone:client:setPayphoneFocus", true)
+ SendNUIMessage({ type = "payphone:open", data = payphone_open_payload() })
+ else
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
+ SendNUIMessage({ type = "payphone:close" })
+ end
+ if active_call_payload then
+ local payload = {}
+ for key, value in pairs(active_call_payload) do
+ payload[key] = value
+ end
+ payload.elapsedSeconds = current_call_elapsed_seconds()
+ SendNUIMessage({ type = "payphone:state", data = payload })
+ end
+end)
+
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then
@@ -625,9 +658,7 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
- if payphone_open then
- SetNuiFocus(false, false)
- end
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
leave_call_voice()
stop_call_visuals()
clear_active_call_state()
diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua
index 0132f7d..2b087cf 100644
--- a/sky_phone/source/client/radio.lua
+++ b/sky_phone/source/client/radio.lua
@@ -120,7 +120,10 @@ local function join_radio(primary, secondary)
return approved
end
- local data = approved.data or {}
+ if type(approved.data) ~= "table" then
+ return { success = false, error = "request_failed" }
+ end
+ local data = approved.data
local approved_primary = tonumber(data.frequency) or 0
local approved_secondary = tonumber(data.secondaryFrequency) or 0
if not Bridge.Radio.Join(approved_primary, approved_secondary) then
@@ -148,27 +151,41 @@ local function leave_radio()
return request("disconnect")
end
-RegisterNUICallback("radio:get", function(_, cb)
+RegisterNUICallback("radio:get", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = request("get")
- if result.success then
+ if result.success and type(result.data) == "table" then
apply_server_state(result.data)
result.data.volume = current_volume
result.data.provider = Bridge.Radio.GetProvider()
result.data.secondarySupported = Bridge.Radio.SupportsSecondary()
+ elseif result.success then
+ result = { success = false, error = "request_failed" }
end
cb(result)
end)
RegisterNUICallback("radio:connect", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(join_radio(data.frequency, data.secondaryFrequency))
end)
-RegisterNUICallback("radio:disconnect", function(_, cb)
+RegisterNUICallback("radio:disconnect", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(leave_radio())
end)
RegisterNUICallback("radio:set-volume", function(data, cb)
- local volume = tonumber(data.volume)
+ local volume = type(data) == "table" and tonumber(data.volume) or nil
if not volume then
cb({ success = false, error = "invalid_volume" })
return
@@ -179,6 +196,10 @@ RegisterNUICallback("radio:set-volume", function(data, cb)
end)
RegisterNUICallback("radio:save-settings", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = request("save-settings", data)
if result.success then
apply_server_state({ settings = result.data })
@@ -187,14 +208,25 @@ RegisterNUICallback("radio:save-settings", function(data, cb)
end)
RegisterNUICallback("radio:save-badge", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(request("save-badge", data))
end)
RegisterNUICallback("radio:save-display-name", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(request("save-display-name", data))
end)
RegisterNetEvent("sky_phone:radio:members", function(data)
+ if type(data) ~= "table" then
+ return
+ end
local frequency = tonumber(data.frequency)
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
if not channel_id then
diff --git a/sky_phone/source/client/skyride.lua b/sky_phone/source/client/skyride.lua
index abf5862..42240c4 100644
--- a/sky_phone/source/client/skyride.lua
+++ b/sky_phone/source/client/skyride.lua
@@ -88,7 +88,11 @@ end
for index = 1, #server_callbacks do
local callback_name = server_callbacks[index]
RegisterNUICallback(callback_name, function(data, cb)
- local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
if not result then
cb({ success = false, error = "request_failed" })
return
diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua
index 537306c..b81e731 100644
--- a/sky_phone/source/server/calls.lua
+++ b/sky_phone/source/server/calls.lua
@@ -1024,31 +1024,45 @@ end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
- payphone_models[model_name] = true
+ if type(model_name) == "string" then
+ payphone_models[model_name] = true
+ end
end
-local function valid_payphone_position(source, data)
- if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then
- return nil
- end
- local x = tonumber(data.coords.x)
- local y = tonumber(data.coords.y)
- local z = tonumber(data.coords.z)
- if not x or not y or not z or x ~= x or y ~= y or z ~= z
- or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0
- then
- return nil
- end
+local payphone_locations, rejected_payphone_locations = SkyPhonePayphones.ValidateLocations(
+ Config.Payphones.Locations,
+ payphone_models
+)
+if Config.Payphones.Enabled and #payphone_locations == 0 then
+ Bridge.Debug(
+ "error",
+ "[sky_phone] Payphones are enabled, but no valid server-owned locations are configured; payphone calls will be rejected.",
+ { always = true }
+ )
+elseif Config.Payphones.Enabled and rejected_payphone_locations > 0 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Ignored %s invalid server-owned payphone location(s).",
+ rejected_payphone_locations,
+ { always = true }
+ )
+end
+
+local function valid_payphone_position(source)
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
return nil
end
local player_coords = GetEntityCoords(ped)
- local booth_coords = vector3(x, y, z)
- if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then
+ local location = SkyPhonePayphones.FindNearest(
+ payphone_locations,
+ player_coords,
+ Config.Payphones.ServerValidationDistance
+ )
+ if not location then
return nil
end
- return booth_coords, data.model
+ return vector3(location.coords.x, location.coords.y, location.coords.z), location.model
end
local function payphone_terminal(number, state)
@@ -1064,10 +1078,13 @@ local function payphone_terminal(number, state)
end
Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
- local booth_coords, booth_model = valid_payphone_position(source, data)
+ local booth_coords, booth_model = valid_payphone_position(source)
if not booth_coords then
return { success = false, error = "invalid_payphone" }
end
diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua
index 8b3ae20..1a4f8ee 100644
--- a/sky_phone/source/server/darkchat.lua
+++ b/sky_phone/source/server/darkchat.lua
@@ -538,12 +538,16 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
media_waveform = voice.waveform
elseif message_type == "image" or message_type == "video" then
local media_type = message_type == "image" and "photo" or "video"
- local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type)
+ local media_url, media_error, resolved_mime = SkyPhoneMedia.ResolveOwnedMedia(
+ source,
+ data.mediaAssetId,
+ media_type
+ )
if not media_url then
return { success = false, error = media_error }
end
media_payload = media_url
- media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
+ media_mime = resolved_mime
elseif message_type == "share" then
local share
local share_error
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 1cf8125..0f43914 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -377,6 +377,7 @@ local schema = {
{ name = "url", type = "TEXT NOT NULL" },
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
+ { name = "mime_type", type = "VARCHAR(120) NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
@@ -2524,6 +2525,37 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
]], {})
+Bridge.Database.Query([[
+ UPDATE `sky_phone_media`
+ SET `mime_type` = CASE
+ WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webm' THEN 'video/webm'
+ WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.mp4' THEN 'video/mp4'
+ WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.png' THEN 'image/png'
+ WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webp' THEN 'image/webp'
+ WHEN `media_type` = 'photo' AND (
+ LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpg'
+ OR LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpeg'
+ ) THEN 'image/jpeg'
+ ELSE NULL
+ END
+ WHERE `mime_type` IS NULL OR `mime_type` = ''
+]], {})
+Bridge.Database.Query([[
+ UPDATE `sky_phone_sms_messages` message
+ INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
+ SET message.`media_mime` = media.`mime_type`
+ WHERE message.`message_type` = 'video'
+ AND media.`mime_type` IN ('video/webm', 'video/mp4')
+ AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
+]], {})
+Bridge.Database.Query([[
+ UPDATE `sky_phone_darkchat_messages` message
+ INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
+ SET message.`media_mime` = media.`mime_type`
+ WHERE message.`message_type` = 'video'
+ AND media.`mime_type` IN ('video/webm', 'video/mp4')
+ AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
+]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua
index c6318b3..b7418da 100644
--- a/sky_phone/source/server/media.lua
+++ b/sky_phone/source/server/media.lua
@@ -3,14 +3,32 @@ SkyPhoneMedia = {}
local pending_uploads = {}
local pending_deletes = {}
+local allowed_remote_mimes = {
+ photo = {
+ ["image/jpeg"] = true,
+ ["image/png"] = true,
+ ["image/webp"] = true,
+ },
+ video = {
+ ["video/mp4"] = true,
+ ["video/webm"] = true,
+ },
+}
local function media_config()
return Config.Media.FiveManage
end
+local function media_api_key()
+ local convar = media_config().ApiKeyConvar
+ if type(convar) ~= "string" or convar == "" then
+ return ""
+ end
+ return GetConvar(convar, "")
+end
+
local function api_configured()
- local api_key = media_config().ApiKey
- return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN"
+ return media_api_key() ~= ""
end
local function http_request(url, method, body, headers, timeout_ms)
@@ -63,7 +81,7 @@ local function request_presigned_url()
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET",
"",
- { ["Authorization"] = config.ApiKey },
+ { ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
local data, response_error = decode_response(response)
@@ -86,7 +104,7 @@ local function get_remote_file(remote_id)
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
"GET",
"",
- { ["Authorization"] = config.ApiKey },
+ { ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
return decode_response(response)
@@ -101,7 +119,7 @@ local function delete_remote_file(remote_id)
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
"DELETE",
"",
- { ["Authorization"] = config.ApiKey },
+ { ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
if response.status < 200 or response.status >= 300 then
@@ -153,7 +171,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
params[#params + 1] = value
end
local rows = Bridge.Database.Query(([[
- SELECT `url`, `media_type` FROM `sky_phone_media`
+ SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -172,7 +190,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
)
return nil, "invalid_attachment"
end
- return media.url
+ return media.url, nil, media.mime_type
end
local function upload_result(source, correlation_id, success, error_code, media)
@@ -224,18 +242,29 @@ local function verify_remote_upload(state, remote_id, uploaded_url)
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
return nil, "invalid_upload"
end
- local remote_type = tostring(remote.type or remote.mimeType or ""):lower()
+ local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
+ local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
+ if remote_mime == "" and allowed_remote_mimes[state.media_type][remote_type] then
+ remote_mime = remote_type
+ end
+ if remote_type == "" then
+ remote_type = remote_mime
+ end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type"
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type"
end
+ if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then
+ return nil, "invalid_media_type"
+ end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token then
return nil, "invalid_upload_token"
end
return {
+ mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type,
remote_id = remote_id,
url = remote.url or uploaded_url,
}
@@ -255,7 +284,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
if not owner then
return error_response
end
- data = data or {}
+ data = type(data) == "table" and data or {}
local limit = math.max(1, math.min(math.floor(tonumber(data.limit) or Config.Media.PageSize), 100))
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
local media_type = data.mediaType
@@ -342,7 +371,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
if #query > 60 or offset < 0 or offset > 500 then
return { success = false, error = "invalid_request" }
end
- local api_key = Config.Media.GiphyApiKey
+ local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "")
if api_key == "" then
return { success = false, error = "gif_provider_unconfigured" }
end
@@ -420,7 +449,7 @@ end)
RegisterNetEvent("sky_phone:media:request-upload", function(data)
local src = source
- data = data or {}
+ data = type(data) == "table" and data or {}
local correlation_id = data.correlationId
local media_type = data.mediaType
if type(correlation_id) ~= "string" or #correlation_id > 80
@@ -454,6 +483,9 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
capture_token = capture_token,
correlation_id = correlation_id,
media_type = media_type,
+ mime_type = media_type == "video" and "video/webm"
+ or ({ png = "image/png", webp = "image/webp" })[tostring(Config.Media.Photo.Encoding):lower()]
+ or "image/jpeg",
owner = owner,
source = src,
}
@@ -474,7 +506,7 @@ end)
RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local src = source
- data = data or {}
+ data = type(data) == "table" and data or {}
local request_id = data.requestId
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
@@ -496,14 +528,14 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local result
if owner.account_id then
result = Bridge.Database.Query([[
- INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
- VALUES (?, NULL, ?, ?, ?)
- ]], { owner.account_id, verified.url, verified.remote_id, state.media_type })
+ INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
+ VALUES (?, NULL, ?, ?, ?, ?)
+ ]], { owner.account_id, verified.url, verified.remote_id, state.media_type, verified.mime_type })
else
result = Bridge.Database.Query([[
- INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
- VALUES (NULL, ?, ?, ?, ?)
- ]], { owner.imei, verified.url, verified.remote_id, state.media_type })
+ INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
+ VALUES (NULL, ?, ?, ?, ?, ?)
+ ]], { owner.imei, verified.url, verified.remote_id, state.media_type, verified.mime_type })
end
pending_uploads[request_id] = nil
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
@@ -522,7 +554,7 @@ end)
RegisterNetEvent("sky_phone:media:cancel-upload", function(data)
local src = source
- local request_id = data and data.requestId
+ local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if state and state.source == src and not state.completing then
pending_uploads[request_id] = nil
@@ -532,7 +564,7 @@ end)
RegisterNetEvent("sky_phone:media:fail-upload", function(data)
local src = source
- local request_id = data and data.requestId
+ local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
@@ -550,7 +582,7 @@ end)
RegisterNetEvent("sky_phone:media:delete", function(data)
local src = source
- data = data or {}
+ data = type(data) == "table" and data or {}
local correlation_id = data.correlationId
local media_id = tonumber(data.id)
if type(correlation_id) ~= "string" or #correlation_id > 80 or not media_id then
@@ -646,6 +678,7 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
- print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
+ print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7")
+ :format(tostring(media_config().ApiKeyConvar)))
end
end)
diff --git a/sky_phone/source/server/messages.lua b/sky_phone/source/server/messages.lua
index 6516f0d..f0868a6 100644
--- a/sky_phone/source/server/messages.lua
+++ b/sky_phone/source/server/messages.lua
@@ -32,7 +32,7 @@ local attachment_assets = {
local attachment_mimes = {
gif = "image/gif",
image = "image/jpeg",
- video = "video/mp4",
+ video = "video/webm",
}
local function allowed_media_url(value)
@@ -208,7 +208,9 @@ local function validate_attachment(source, device, message_type, data)
return nil
end
local payload = data.mediaAssetId
- if not valid_attachment_asset(message_type, payload) then
+ local built_in_asset = valid_attachment_asset(message_type, payload)
+ local mime = built_in_asset and attachment_mimes[message_type] or nil
+ if not built_in_asset then
if message_type == "gif" then
return nil
end
@@ -226,7 +228,7 @@ local function validate_attachment(source, device, message_type, data)
params = { media_id, device.imei }
end
local rows = Bridge.Database.Query(([[
- SELECT `url`, `media_type` FROM `sky_phone_media`
+ SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -241,6 +243,7 @@ local function validate_attachment(source, device, message_type, data)
return nil
end
payload = media.url
+ mime = type(media.mime_type) == "string" and media.mime_type ~= "" and media.mime_type or nil
end
local duration = nil
if message_type == "video" and data.mediaDurationMs ~= nil then
@@ -252,7 +255,7 @@ local function validate_attachment(source, device, message_type, data)
end
return {
duration = duration,
- mime = attachment_mimes[message_type],
+ mime = mime,
payload = payload,
}
end
diff --git a/sky_phone/source/server/payphones.lua b/sky_phone/source/server/payphones.lua
new file mode 100644
index 0000000..3a1fa47
--- /dev/null
+++ b/sky_phone/source/server/payphones.lua
@@ -0,0 +1,105 @@
+SkyPhonePayphones = {}
+
+local maximum_horizontal_coordinate = 10000.0
+local maximum_vertical_coordinate = 2000.0
+
+local function finite_number(value)
+ if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
+ return nil
+ end
+ return value
+end
+
+local function normalize_coordinates(value, allow_vector)
+ local value_type = type(value)
+ if value_type ~= "table" and (not allow_vector or value_type ~= "vector3") then
+ return nil
+ end
+
+ local x = finite_number(value.x)
+ local y = finite_number(value.y)
+ local z = finite_number(value.z)
+ if not x or not y or not z
+ or math.abs(x) > maximum_horizontal_coordinate
+ or math.abs(y) > maximum_horizontal_coordinate
+ or math.abs(z) > maximum_vertical_coordinate
+ then
+ return nil
+ end
+
+ return { x = x, y = y, z = z }
+end
+
+local function normalize_location(location, allowed_models)
+ if type(location) ~= "table" or type(location.model) ~= "string" or not allowed_models[location.model] then
+ return nil
+ end
+
+ local coords = normalize_coordinates(location.coords, false)
+ if not coords then
+ return nil
+ end
+
+ return {
+ model = location.model,
+ coords = coords,
+ }
+end
+
+function SkyPhonePayphones.ValidateLocations(locations, allowed_models)
+ if type(locations) ~= "table" or type(allowed_models) ~= "table" then
+ return {}, 0
+ end
+
+ local validated = {}
+ local rejected = 0
+ for index, location in pairs(locations) do
+ local valid_index = type(index) == "number" and index >= 1 and index % 1 == 0
+ local normalized = valid_index and normalize_location(location, allowed_models) or nil
+ if normalized then
+ normalized.index = index
+ validated[#validated + 1] = normalized
+ else
+ rejected = rejected + 1
+ end
+ end
+
+ table.sort(validated, function(left, right)
+ return left.index < right.index
+ end)
+ for index = 1, #validated do
+ validated[index].index = nil
+ end
+
+ return validated, rejected
+end
+
+function SkyPhonePayphones.FindNearest(locations, player_coords, maximum_distance)
+ if type(locations) ~= "table" then
+ return nil
+ end
+
+ local coords = normalize_coordinates(player_coords, true)
+ local distance = finite_number(maximum_distance)
+ if not coords or not distance or distance <= 0 then
+ return nil
+ end
+
+ local nearest = nil
+ local nearest_distance_squared = distance * distance
+ for index = 1, #locations do
+ local location = locations[index]
+ if type(location) == "table" and type(location.coords) == "table" then
+ local dx = coords.x - location.coords.x
+ local dy = coords.y - location.coords.y
+ local dz = coords.z - location.coords.z
+ local distance_squared = dx * dx + dy * dy + dz * dz
+ if distance_squared <= nearest_distance_squared then
+ nearest = location
+ nearest_distance_squared = distance_squared
+ end
+ end
+ end
+
+ return nearest
+end
diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua
index 91d0a0f..97c003d 100644
--- a/sky_phone/source/server/phone.lua
+++ b/sky_phone/source/server/phone.lua
@@ -914,15 +914,20 @@ function SkyPhone.OpenDeviceForCall(source, imei)
return false
end
local security = load_device_security(imei)
- if sessions[source] and sessions[source].imei ~= imei then
+ local existing_session = sessions[source]
+ if existing_session and existing_session.imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
- sessions[source] = {
- imei = imei,
- slot = matches[1].slot,
- token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
- unlocked = security == nil,
- }
+ if existing_session and existing_session.imei == imei then
+ existing_session.slot = matches[1].slot
+ else
+ sessions[source] = {
+ imei = imei,
+ slot = matches[1].slot,
+ token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
+ unlocked = security == nil,
+ }
+ end
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
@@ -1075,7 +1080,13 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
if not session then
return error_response
end
- if type(data) ~= "table" or not allowed_device_namespaces[data.namespace] then
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
+ if data.imei ~= session.imei or data.sessionToken ~= session.token then
+ return { success = false, error = "stale_session" }
+ end
+ if not allowed_device_namespaces[data.namespace] then
return { success = false, error = "invalid_namespace" }
end
diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua
index 34bde46..fe6f3c4 100644
--- a/sky_phone/source/server/sim.lua
+++ b/sky_phone/source/server/sim.lua
@@ -406,6 +406,14 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
return insert_sim(source, data.imei, data.confirmed == true)
end)
+Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source)
+ if operation_locks[source] then
+ return { success = false, error = "operation_in_progress" }
+ end
+ pending_insertions[source] = nil
+ return { success = true }
+end)
+
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
diff --git a/tests/client_focus.lua b/tests/client_focus.lua
new file mode 100644
index 0000000..f70ae6c
--- /dev/null
+++ b/tests/client_focus.lua
@@ -0,0 +1,67 @@
+dofile("sky_phone/source/client/focus.lua")
+
+local function resolve(overrides)
+ local state = {
+ activity_suspended = false,
+ call_focus = false,
+ camera_active = false,
+ camera_nui_focused = true,
+ is_open = false,
+ notification_focus = false,
+ payphone_focus = false,
+ sim_picker_open = false,
+ }
+ for key, value in pairs(overrides or {}) do
+ state[key] = value
+ end
+ return SkyPhoneFocus.Resolve(state)
+end
+
+local idle = resolve()
+assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override")
+
+local minimized_call = resolve()
+assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused")
+
+local incoming_call = resolve({ call_focus = true })
+assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI")
+
+local camera_game_input = resolve({
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ not camera_game_input.focused and camera_game_input.keep_input,
+ "unfocused camera must own game input over the open phone"
+)
+
+local camera_interrupted_by_call = resolve({
+ call_focus = true,
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input,
+ "incoming call attention must override unfocused camera input"
+)
+
+local camera_after_connected_call = resolve({
+ call_focus = false,
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ not camera_after_connected_call.focused and camera_after_connected_call.keep_input,
+ "connected call without an attention claim must restore unfocused camera input"
+)
+
+local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false })
+assert(payphone_closed_behind_phone.focused, "releasing payphone focus must not clear mobile phone focus")
+
+local suspended = resolve({ activity_suspended = true, call_focus = true, is_open = true })
+assert(not suspended.focused and not suspended.keep_input, "suspended activities must mask every focus claim")
+
+print("Client focus tests passed")
diff --git a/tests/server_payphones.lua b/tests/server_payphones.lua
new file mode 100644
index 0000000..1798a08
--- /dev/null
+++ b/tests/server_payphones.lua
@@ -0,0 +1,39 @@
+dofile("sky_phone/source/server/payphones.lua")
+
+local allowed_models = {
+ prop_phonebox_01a = true,
+ prop_phonebox_04 = true,
+}
+
+local locations, rejected = SkyPhonePayphones.ValidateLocations({
+ { model = "prop_phonebox_01a", coords = { x = 100.0, y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_04", coords = { x = 105.0, y = 200.0, z = 30.0 } },
+ { model = "not_allowed", coords = { x = 100.0, y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_01a", coords = { x = "100", y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_01a", coords = { x = 10001.0, y = 200.0, z = 30.0 } },
+ "malformed",
+}, allowed_models)
+
+assert(#locations == 2, "only strictly valid configured locations must be accepted")
+assert(rejected == 4, "every malformed or disallowed configured location must be reported")
+
+local first = SkyPhonePayphones.FindNearest(locations, { x = 101.0, y = 200.0, z = 30.0 }, 3.0)
+assert(first and first.model == "prop_phonebox_01a", "nearest configured booth must be selected")
+
+local second = SkyPhonePayphones.FindNearest(locations, { x = 104.0, y = 200.0, z = 30.0 }, 3.0)
+assert(second and second.model == "prop_phonebox_04", "another configured booth must be selected by proximity")
+
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = 0.0, y = 0.0, z = 0.0 }, 3.0),
+ "a player away from every configured booth must be rejected"
+)
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = "100", y = 200.0, z = 30.0 }, 3.0),
+ "malformed player coordinates must be rejected"
+)
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = 100.0, y = 200.0, z = 30.0 }, 0.0),
+ "an invalid validation distance must be rejected"
+)
+
+print("Server payphone validation tests passed")
From c3b7a0871ae5468e4b33b36bdc1df14504e8bb61 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Wed, 12 Aug 2026 18:59:05 +0200
Subject: [PATCH 38/63] FIX - harden phone lifecycle and server authority
---
frontend/src/App.vue | 113 ++++++--
frontend/src/components/PayphoneOverlay.vue | 6 +-
frontend/src/components/SimPhonePicker.vue | 1 -
frontend/src/stores/banking.test.ts | 24 +-
frontend/src/stores/banking.ts | 20 +-
frontend/src/stores/mail.test.ts | 106 +++++++-
frontend/src/stores/mail.ts | 48 +++-
frontend/src/stores/notifications.test.ts | 23 ++
frontend/src/stores/notifications.ts | 13 +-
frontend/src/stores/phone-persistence.test.ts | 170 ++++++++++++
frontend/src/stores/phone.ts | 78 +++++-
frontend/src/utils/nui.test.ts | 63 +++++
frontend/src/utils/nui.ts | 12 +-
sky_phone/config/payphones.lua | 248 ++++++++++++++++++
sky_phone/fxmanifest.lua | 3 +
sky_phone/source/client/camera.lua | 136 +++++++---
sky_phone/source/client/crewlink.lua | 4 +
sky_phone/source/client/focus.lua | 21 ++
sky_phone/source/client/garage.lua | 4 +
sky_phone/source/client/housing.lua | 53 ++--
sky_phone/source/client/main.lua | 244 ++++++++++++++---
sky_phone/source/client/payphones.lua | 69 +++--
sky_phone/source/client/radio.lua | 42 ++-
sky_phone/source/client/skyride.lua | 6 +-
sky_phone/source/server/calls.lua | 51 ++--
sky_phone/source/server/payphones.lua | 105 ++++++++
sky_phone/source/server/phone.lua | 27 +-
sky_phone/source/server/sim.lua | 8 +
tests/client_focus.lua | 67 +++++
tests/server_payphones.lua | 39 +++
30 files changed, 1630 insertions(+), 174 deletions(-)
create mode 100644 frontend/src/stores/phone-persistence.test.ts
create mode 100644 frontend/src/utils/nui.test.ts
create mode 100644 sky_phone/config/payphones.lua
create mode 100644 sky_phone/source/client/focus.lua
create mode 100644 sky_phone/source/server/payphones.lua
create mode 100644 tests/client_focus.lua
create mode 100644 tests/server_payphones.lua
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index acf5d8b..bbf35c1 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -237,6 +237,9 @@ const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
const DEVELOPMENT_PHONE_SCALE = 1.25
+const PHONE_PORTRAIT_WIDTH = 390
+const PHONE_PORTRAIT_HEIGHT = 844
+const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
@@ -291,11 +294,34 @@ const phoneBaseZoom = computed(
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
+const phoneZoom = computed(() => {
+ const preferred =
+ phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
+ if (isDevelopment) return preferred
+
+ const edgeGap = 24 * viewportScale.value
+ const shellWidth = phone.cameraLandscape
+ ? PHONE_PORTRAIT_HEIGHT
+ : PHONE_PORTRAIT_WIDTH
+ const shellHeight = phone.cameraLandscape
+ ? PHONE_PORTRAIT_WIDTH
+ : PHONE_PORTRAIT_HEIGHT
+ const viewportMaximum = Math.max(
+ 0,
+ Math.min(
+ (window.innerWidth - edgeGap) / shellWidth,
+ (window.innerHeight - edgeGap) / shellHeight,
+ ),
+ )
+ return Math.min(
+ viewportMaximum,
+ Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred),
+ )
+})
const phoneResolutionStyle = computed(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
- '--phone-zoom':
- phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
+ '--phone-zoom': phoneZoom.value,
}))
const phoneStageStyle = computed(() => ({
...phoneResolutionStyle.value,
@@ -315,6 +341,8 @@ let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
+let phoneClosePending = false
+let simPickerClosePending = false
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -505,7 +533,7 @@ function onMessage(event: MessageEvent): void {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
activitySuspended.value = false
- phone.close()
+ phone.endDeviceSession()
} else if (event.data?.type === 'app:suspend') {
activitySuspended.value = true
} else if (event.data?.type === 'app:resume') {
@@ -899,14 +927,69 @@ function onMessage(event: MessageEvent): void {
}
}
+async function closeSimPicker(): Promise {
+ if (simPickerClosePending || !simPicker.value) return
+ simPickerClosePending = true
+ const closingPicker = simPicker.value
+ try {
+ const response = await nuiCall('sim:picker-close')
+ if (response.success && simPicker.value === closingPicker) {
+ simPicker.value = null
+ }
+ } finally {
+ simPickerClosePending = false
+ }
+}
+
+async function closePhone(): Promise {
+ if (phoneClosePending || !phone.isOpen) return
+ phoneClosePending = true
+ const closingGeneration = phone.persistenceGeneration
+ const closingImei = phone.device?.imei ?? null
+ const closingToken = phone.deviceSessionToken
+ try {
+ await phone.flushDevicePersistence()
+ if (
+ !phone.isOpen ||
+ phone.persistenceGeneration !== closingGeneration ||
+ (phone.device?.imei ?? null) !== closingImei ||
+ phone.deviceSessionToken !== closingToken
+ ) {
+ return
+ }
+ const response = await nuiCall('close')
+ if (
+ !response.success ||
+ !phone.isOpen ||
+ phone.persistenceGeneration !== closingGeneration ||
+ (phone.device?.imei ?? null) !== closingImei ||
+ phone.deviceSessionToken !== closingToken
+ ) {
+ return
+ }
+ phone.endDeviceSession()
+ } finally {
+ phoneClosePending = false
+ }
+}
+
function onKeydown(event: KeyboardEvent): void {
- if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return
- if (controlCenterOpened.value) {
- controlCenterOpened.value = false
+ if (event.key !== 'Escape') return
+ if (simPicker.value) {
+ event.preventDefault()
+ void closeSimPicker()
return
}
- phone.close()
- void nuiCall('close')
+
+ queueMicrotask(() => {
+ if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
+ return
+ if (controlCenterOpened.value) {
+ controlCenterOpened.value = false
+ return
+ }
+ void closePhone()
+ })
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -1051,7 +1134,7 @@ onMounted(() => {
window.addEventListener('resize', updateViewportScale)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
- void nuiCall('ui:ready')
+ void nuiCall('ui:ready', { protocolVersion: 1 })
clockTicker = setInterval(() => {
const now = Date.now()
for (const alarm of clock.dueAlarms(now)) {
@@ -1116,11 +1199,9 @@ watch(
)
watch(
- [() => notifications.requiresAttention, () => calls.activeCall],
- ([requiresAttention, activeCall]) => {
- void nuiCall('notification:focus', {
- active: requiresAttention || activeCall !== null,
- })
+ () => notifications.requiresAttention,
+ (requiresAttention) => {
+ void nuiCall('notification:focus', { active: requiresAttention })
},
)
@@ -1204,7 +1285,7 @@ onBeforeUnmount(() => {
v-if="simPicker"
:choices="simPicker.choices"
:number="simPicker.number"
- @close="simPicker = null"
+ @close="closeSimPicker"
/>
{
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
+ 'phone-app--darkchat': route.params.appId === 'darkchat',
'phone-app--light': !phone.isDarkMode,
+ 'phone-app--messages': route.params.appId === 'messages',
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue
index cf547c1..eb1fbd3 100644
--- a/frontend/src/components/PayphoneOverlay.vue
+++ b/frontend/src/components/PayphoneOverlay.vue
@@ -239,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
if (!visible.value) return
if (event.key === 'Escape') {
event.preventDefault()
+ event.stopImmediatePropagation()
void close()
return
}
@@ -256,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
prepareButtonSounds()
window.addEventListener('message', onMessage)
- window.addEventListener('keydown', onKeydown)
+ window.addEventListener('keydown', onKeydown, true)
ticker = window.setInterval(() => {
now.value = Date.now()
}, 250)
@@ -264,7 +265,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
- window.removeEventListener('keydown', onKeydown)
+ window.removeEventListener('keydown', onKeydown, true)
if (ticker !== undefined) window.clearInterval(ticker)
for (const sound of buttonSounds) {
sound.pause()
@@ -399,6 +400,7 @@ onBeforeUnmount(() => {
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
+ pointer-events: auto;
user-select: none;
}
diff --git a/frontend/src/components/SimPhonePicker.vue b/frontend/src/components/SimPhonePicker.vue
index f5e0a4e..6db474d 100644
--- a/frontend/src/components/SimPhonePicker.vue
+++ b/frontend/src/components/SimPhonePicker.vue
@@ -42,7 +42,6 @@ async function insert(
}
function close(): void {
- void nuiCall('sim:picker-close')
emit('close')
}
diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts
index 338c39b..52e1062 100644
--- a/frontend/src/stores/banking.test.ts
+++ b/frontend/src/stores/banking.test.ts
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
-import { nuiCall } from '@/utils/nui'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
@@ -60,4 +60,26 @@ describe('banking store', () => {
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
+
+ it('does not let an older response overwrite the newest overview', async () => {
+ let resolveOlder!: (response: NuiResponse) => void
+ const olderResponse = new Promise>(
+ (resolve) => {
+ resolveOlder = resolve
+ },
+ )
+ const newest = { ...overview, bank: 23000 }
+ mockNuiCall
+ .mockReturnValueOnce(olderResponse)
+ .mockResolvedValueOnce({ data: newest, success: true })
+ const banking = useBankingStore()
+
+ const olderRequest = banking.load()
+ await banking.load()
+ resolveOlder({ data: { ...overview, bank: 1 }, success: true })
+ await olderRequest
+
+ expect(banking.overview).toEqual(newest)
+ expect(banking.isLoading).toBe(false)
+ })
})
diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts
index dab3572..4c8ba80 100644
--- a/frontend/src/stores/banking.ts
+++ b/frontend/src/stores/banking.ts
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
error: '',
isLoading: false,
overview: null as BankingOverview | null,
+ pendingRequests: 0,
+ requestGeneration: 0,
}),
actions: {
async load(): Promise {
+ const generation = ++this.requestGeneration
+ this.pendingRequests += 1
this.isLoading = true
- const response = await nuiCall('banking:overview')
- this.isLoading = false
+ const response = await nuiCall('banking:overview').finally(
+ () => {
+ this.pendingRequests = Math.max(0, this.pendingRequests - 1)
+ this.isLoading = this.pendingRequests > 0
+ },
+ )
+ if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
amount: number,
phoneNumber?: string,
): Promise> {
+ const generation = ++this.requestGeneration
+ this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall(`banking:${action}`, {
amount,
...(phoneNumber === undefined ? {} : { phoneNumber }),
+ }).finally(() => {
+ this.pendingRequests = Math.max(0, this.pendingRequests - 1)
+ this.isLoading = this.pendingRequests > 0
})
- this.isLoading = false
+ if (generation !== this.requestGeneration) return response
if (response.success && response.data) {
this.overview = response.data
this.error = ''
diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts
index bcf80cc..17e7a3a 100644
--- a/frontend/src/stores/mail.test.ts
+++ b/frontend/src/stores/mail.test.ts
@@ -1,9 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
-import type { MailCounts, MailListItem } from '@/types/mail'
-import { nuiCall } from '@/utils/nui'
+import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
@@ -45,7 +46,7 @@ describe('mail store', () => {
success: true,
})
.mockResolvedValueOnce({
- data: { hasMore: false, items: [listItem(2)] },
+ data: { hasMore: false, items: [listItem(2)], offset: 0 },
success: true,
})
@@ -129,4 +130,103 @@ describe('mail store', () => {
expect(mail.folder).toBe('inbox')
expect(mail.search).toBe('')
})
+
+ it('ignores an older folder response after a newer navigation', async () => {
+ let resolveOlder!: (response: NuiResponse) => void
+ const olderResponse = new Promise>(
+ (resolve) => {
+ resolveOlder = resolve
+ },
+ )
+ mockNuiCall
+ .mockReturnValueOnce(olderResponse)
+ .mockResolvedValueOnce({
+ data: { hasMore: false, items: [listItem(2)] },
+ success: true,
+ })
+ const mail = useMailStore()
+
+ const olderRequest = mail.loadFolder('inbox')
+ await mail.loadFolder('sent')
+ resolveOlder({
+ data: { hasMore: false, items: [listItem(1)], offset: 0 },
+ success: true,
+ })
+ await olderRequest
+
+ expect(mail.folder).toBe('sent')
+ expect(mail.items.map((item) => item.id)).toEqual([2])
+ expect(mail.loading).toBe(false)
+ })
+
+ it('ignores mailbox counts returned after the session was cleared', async () => {
+ let resolveCounts!: (response: NuiResponse) => void
+ mockNuiCall.mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveCounts = resolve
+ }),
+ )
+ const mail = useMailStore()
+
+ const bootstrap = mail.bootstrap('alex@ifruit.com')
+ await mail.bootstrap('')
+ resolveCounts({ data: counts, success: true })
+ await bootstrap
+
+ expect(mail.accountEmail).toBe('')
+ expect(mail.counts).toEqual({
+ drafts: 0,
+ inbox: 0,
+ sent: 0,
+ trash: 0,
+ unread: 0,
+ })
+ })
+
+ it('ignores a late login after the mailbox session was cleared', async () => {
+ let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
+ mockNuiCall.mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveLogin = resolve
+ }),
+ )
+ const mail = useMailStore()
+ const account = useAccountStore()
+
+ const login = mail.login('alex', 'secret')
+ await mail.bootstrap('')
+ resolveLogin({
+ data: { devices: [], email: 'alex@ifruit.com' },
+ success: true,
+ })
+ await login
+
+ expect(mail.accountEmail).toBe('')
+ expect(account.email).toBe('')
+ })
+
+ it('ignores a late login after an external mailbox session change', async () => {
+ let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
+ mockNuiCall
+ .mockReturnValueOnce(
+ new Promise>((resolve) => {
+ resolveLogin = resolve
+ }),
+ )
+ .mockResolvedValueOnce({ data: counts, success: true })
+ const mail = useMailStore()
+ const account = useAccountStore()
+
+ const login = mail.login('alex', 'secret')
+ account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
+ await mail.bootstrap('morgan@ifruit.com')
+ resolveLogin({
+ data: { devices: [], email: 'alex@ifruit.com' },
+ success: true,
+ })
+ await login
+
+ expect(mail.accountEmail).toBe('morgan@ifruit.com')
+ expect(account.email).toBe('morgan@ifruit.com')
+ })
})
diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts
index 238bea6..01a4c5b 100644
--- a/frontend/src/stores/mail.ts
+++ b/frontend/src/stores/mail.ts
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
const items = ref([])
const loading = ref(false)
const search = ref('')
+ let authenticationGeneration = 0
+ let folderRequestGeneration = 0
+ let sessionGeneration = 0
function clearSession(): void {
+ authenticationGeneration += 1
+ sessionGeneration += 1
+ folderRequestGeneration += 1
accountEmail.value = ''
counts.value = emptyCounts()
items.value = []
hasMore.value = false
folder.value = 'inbox'
search.value = ''
+ loading.value = false
}
async function bootstrap(email: string): Promise {
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
clearSession()
return
}
+ authenticationGeneration += 1
+ sessionGeneration += 1
+ folderRequestGeneration += 1
accountEmail.value = email
await refreshCounts()
}
async function login(email: string, password: string) {
+ const generation = ++authenticationGeneration
const response = await nuiCall('mail:login', {
email,
password,
})
- if (response.success && response.data) {
+ if (
+ generation === authenticationGeneration &&
+ response.success &&
+ response.data
+ ) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
}
async function register(email: string, password: string) {
+ const generation = ++authenticationGeneration
const response = await nuiCall('mail:register', {
email,
password,
})
- if (response.success && response.data) {
+ if (
+ generation === authenticationGeneration &&
+ response.success &&
+ response.data
+ ) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
}
async function logout(): Promise {
+ const generation = ++authenticationGeneration
if (accountEmail.value) {
const response = await nuiCall('mail:logout')
+ if (generation !== authenticationGeneration) return
if (response.success) account.hydrate(null)
}
+ if (generation !== authenticationGeneration) return
clearSession()
}
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
nextSearch = '',
append = false,
): Promise {
+ const generation = ++folderRequestGeneration
+ const session = sessionGeneration
loading.value = true
const offset = append ? items.value.length : 0
const response = await nuiCall('mail:list', {
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
offset,
search: nextSearch,
})
- loading.value = false
+ if (generation === folderRequestGeneration) loading.value = false
+ if (
+ generation !== folderRequestGeneration ||
+ session !== sessionGeneration
+ ) {
+ return false
+ }
if (!response.success || !response.data) return false
folder.value = nextFolder
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
}
async function refreshCounts(): Promise {
+ const email = accountEmail.value
+ const session = sessionGeneration
const response = await nuiCall('mail:counts')
- if (response.success && response.data) counts.value = response.data
+ if (
+ session === sessionGeneration &&
+ email === accountEmail.value &&
+ response.success &&
+ response.data
+ ) {
+ counts.value = response.data
+ }
}
async function openMessage(id: number): Promise {
diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts
index e38b885..59eff5f 100644
--- a/frontend/src/stores/notifications.test.ts
+++ b/frontend/src/stores/notifications.test.ts
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ MAX_LOCK_SCREEN_NOTIFICATIONS,
useNotificationsStore,
type PhoneNotificationDevice,
} from '@/stores/notifications'
@@ -256,4 +257,26 @@ describe('notifications store', () => {
notifications.clearLockScreen()
expect(notifications.lockScreenNotifications).toEqual([])
})
+
+ it('bounds persisted lock screen history to the newest notifications', () => {
+ openPhone('111')
+ const notifications = useNotificationsStore()
+ const items = Array.from(
+ { length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 },
+ (_, index) => ({
+ appId: 'mail' as const,
+ id: `saved-${index}`,
+ text: `Message ${index}`,
+ title: 'Mail',
+ }),
+ )
+
+ notifications.hydrate({ items, version: 1 }, '111')
+
+ expect(notifications.lockScreenNotifications).toHaveLength(
+ MAX_LOCK_SCREEN_NOTIFICATIONS,
+ )
+ expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59')
+ expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10')
+ })
})
diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts
index 1487a2c..2777000 100644
--- a/frontend/src/stores/notifications.ts
+++ b/frontend/src/stores/notifications.ts
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
version: 1
}
+export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
+
const timeoutHandles = new Map>()
const stopToneHandles = new Map void>()
const persistenceQueues = new Map>()
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
for (const notification of stored) merged.set(notification.id, notification)
for (const notification of lockScreenQueues.value[imei] ?? [])
merged.set(notification.id, notification)
- lockScreenQueues.value[imei] = [...merged.values()]
+ lockScreenQueues.value[imei] = [...merged.values()].slice(
+ -MAX_LOCK_SCREEN_NOTIFICATIONS,
+ )
persist(imei)
}
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
function remember(notification: PhoneNotification): void {
const imei = notification.device?.imei ?? phone.device?.imei
if (!imei) return
- const notifications = lockScreenQueues.value[imei] ?? []
- notifications.push(notification)
- lockScreenQueues.value[imei] = notifications
+ lockScreenQueues.value[imei] = [
+ ...(lockScreenQueues.value[imei] ?? []),
+ notification,
+ ].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
persist(imei)
}
diff --git a/frontend/src/stores/phone-persistence.test.ts b/frontend/src/stores/phone-persistence.test.ts
new file mode 100644
index 0000000..1c4027a
--- /dev/null
+++ b/frontend/src/stores/phone-persistence.test.ts
@@ -0,0 +1,170 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { usePhoneStore } from '@/stores/phone'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({
+ nuiCall: vi.fn(),
+}))
+
+const mockNuiCall = vi.mocked(nuiCall)
+
+function deferredResponse(): {
+ promise: Promise>
+ resolve: (response: NuiResponse) => void
+} {
+ let resolve!: (response: NuiResponse) => void
+ const promise = new Promise>((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+}
+
+function openPhone(imei: string, token: string, revision: number): void {
+ usePhoneStore().open({
+ device: {
+ data: { settings: { payload: {}, revision } },
+ imei,
+ name: `Phone ${imei}`,
+ sim: null,
+ },
+ token,
+ })
+}
+
+describe('phone device persistence scope', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ matchMedia: vi.fn(() => ({ matches: false })),
+ })
+ setActivePinia(createPinia())
+ mockNuiCall.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('does not apply a late save response to a newer device session', async () => {
+ const stale = deferredResponse<{ revision: number }>()
+ mockNuiCall.mockReturnValueOnce(stale.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 2)
+
+ phone.saveDeviceNamespace('settings', { value: 'old' })
+ await Promise.resolve()
+ expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
+ imei: '111',
+ namespace: 'settings',
+ payload: { value: 'old' },
+ revision: 2,
+ sessionToken: 'session-a',
+ })
+
+ openPhone('222', 'session-b', 7)
+ stale.resolve({ data: { revision: 3 }, success: true })
+ await stale.promise
+ await Promise.resolve()
+
+ expect(phone.device?.imei).toBe('222')
+ expect(phone.deviceRevisions.settings).toBe(7)
+ })
+
+ it('drops queued writes from an obsolete device generation', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall.mockReturnValueOnce(first.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ openPhone('222', 'session-b', 0)
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(1)
+ })
+
+ it('flushes every queued write after a normal visibility close', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValueOnce({ data: { revision: 2 }, success: true })
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ phone.close()
+ const flushed = phone.flushDevicePersistence()
+
+ first.resolve({ data: { revision: 1 }, success: true })
+ await flushed
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
+ imei: '111',
+ namespace: 'settings',
+ payload: { order: 2 },
+ revision: 1,
+ sessionToken: 'session-a',
+ })
+ expect(phone.deviceRevisions.settings).toBe(2)
+ })
+
+ it('keeps queued writes scoped across a same-session bootstrap update', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValueOnce({ data: { revision: 2 }, success: true })
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ phone.saveDeviceNamespace('settings', { order: 2 })
+ await Promise.resolve()
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ openPhone('111', 'session-a', 1)
+ await phone.flushDevicePersistence()
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(phone.deviceRevisions.settings).toBe(2)
+ })
+
+ it('waits for writes queued while a persistence flush is in progress', async () => {
+ const first = deferredResponse<{ revision: number }>()
+ const queuedDuringFlush = deferredResponse<{ revision: number }>()
+ mockNuiCall
+ .mockReturnValueOnce(first.promise)
+ .mockReturnValueOnce(queuedDuringFlush.promise)
+ const phone = usePhoneStore()
+ openPhone('111', 'session-a', 0)
+
+ phone.saveDeviceNamespace('settings', { order: 1 })
+ await Promise.resolve()
+ let flushCompleted = false
+ const flushed = phone.flushDevicePersistence().then(() => {
+ flushCompleted = true
+ })
+ phone.saveDeviceNamespace('widgets', { order: 2 })
+ await Promise.resolve()
+
+ first.resolve({ data: { revision: 1 }, success: true })
+ await first.promise
+ await Promise.resolve()
+ expect(flushCompleted).toBe(false)
+
+ queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
+ await flushed
+
+ expect(mockNuiCall).toHaveBeenCalledTimes(2)
+ expect(phone.deviceRevisions.widgets).toBe(1)
+ })
+})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 8fdfddb..dd8263a 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
+ clampPhoneScale,
ensureAppNotificationPreferences,
parsePhonePreferences,
type AppNotificationPreferences,
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
}
const namespaceQueues = new Map>()
+let nextPersistenceSession = 0
const companiesFallbackLocales = {
name: 'Companies',
@@ -3617,11 +3619,14 @@ export const usePhoneStore = defineStore('phone', {
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record,
+ deviceSessionToken: null as string | null,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
+ persistenceGeneration: 0,
+ persistenceSession: ++nextPersistenceSession,
security: {
enabled: false,
length: null,
@@ -3642,6 +3647,15 @@ export const usePhoneStore = defineStore('phone', {
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
+ const nextImei = payload.device?.imei ?? this.device?.imei ?? null
+ const nextToken = payload.token ?? this.deviceSessionToken
+ if (
+ nextImei !== (this.device?.imei ?? null) ||
+ nextToken !== this.deviceSessionToken
+ ) {
+ this.persistenceGeneration += 1
+ }
+ this.deviceSessionToken = nextToken
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
@@ -3652,6 +3666,13 @@ export const usePhoneStore = defineStore('phone', {
}
this.isOpen = true
},
+ endDeviceSession(): void {
+ this.close()
+ if (this.deviceSessionToken !== null) {
+ this.deviceSessionToken = null
+ this.persistenceGeneration += 1
+ }
+ },
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
@@ -3665,22 +3686,59 @@ export const usePhoneStore = defineStore('phone', {
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
- const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
+ const imei = this.device?.imei
+ if (!imei) {
+ console.error(
+ `[Phone persistence] Could not save ${namespace} without an active device.`,
+ )
+ return
+ }
+ const generation = this.persistenceGeneration
+ const session = this.persistenceSession
+ const token = this.deviceSessionToken
+ const queuedPayload = cloneJsonData(payload)
+ const queueKey = `${session}:${generation}:${imei}:${namespace}`
+ const isCurrentScope = (): boolean =>
+ this.persistenceSession === session &&
+ this.persistenceGeneration === generation &&
+ this.device?.imei === imei &&
+ this.deviceSessionToken === token
+ const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
+ if (!isCurrentScope()) return
const response = await nuiCall<{ revision: number }>('device:save', {
+ imei,
namespace,
- payload,
+ payload: queuedPayload,
revision: this.deviceRevisions[namespace] ?? 0,
+ sessionToken: token,
})
- if (response.success && response.data) {
- this.deviceRevisions[namespace] = response.data.revision
+ if (
+ isCurrentScope() &&
+ response.success &&
+ Number.isInteger(response.data?.revision) &&
+ Number(response.data?.revision) >= 0
+ ) {
+ this.deviceRevisions[namespace] = Number(response.data?.revision)
}
})
const tracked = queued.finally(() => {
- if (namespaceQueues.get(namespace) === tracked)
- namespaceQueues.delete(namespace)
+ if (namespaceQueues.get(queueKey) === tracked)
+ namespaceQueues.delete(queueKey)
})
- namespaceQueues.set(namespace, tracked)
+ namespaceQueues.set(queueKey, tracked)
+ },
+ async flushDevicePersistence(): Promise {
+ const imei = this.device?.imei
+ if (!imei) return
+ const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
+ while (true) {
+ const activeQueues = [...namespaceQueues.entries()]
+ .filter(([key]) => key.startsWith(queuePrefix))
+ .map(([, queue]) => queue)
+ if (!activeQueues.length) return
+ await Promise.all(activeQueues)
+ }
},
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
@@ -3706,7 +3764,11 @@ export const usePhoneStore = defineStore('phone', {
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
- this.preferences.settings[key] = value
+ this.preferences.settings[key] = (
+ key === 'phoneScale'
+ ? clampPhoneScale(Number(value))
+ : value
+ ) as PhonePreferencesV1['settings'][K]
this.saveDeviceNamespace('settings', this.preferences)
},
setAlertVolumes(value: number): void {
diff --git a/frontend/src/utils/nui.test.ts b/frontend/src/utils/nui.test.ts
new file mode 100644
index 0000000..39dc5e2
--- /dev/null
+++ b/frontend/src/utils/nui.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { nuiCall } from '@/utils/nui'
+
+describe('nuiCall', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('window', {
+ clearTimeout: globalThis.clearTimeout,
+ location: { search: '' },
+ setTimeout: globalThis.setTimeout,
+ })
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ it('clears the request timeout after a successful callback', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: { value: 1 }, success: true }), {
+ headers: { 'Content-Type': 'application/json' },
+ status: 200,
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({
+ data: { value: 1 },
+ success: true,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'http://localhost:3002/api/test',
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ )
+ })
+
+ it('aborts a callback that never completes', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ reject(new DOMException('Aborted', 'AbortError'))
+ })
+ }),
+ ),
+ )
+
+ const request = nuiCall('never-responds')
+ await vi.advanceTimersByTimeAsync(20_000)
+
+ await expect(request).resolves.toEqual({
+ error: 'request_timeout',
+ success: false,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts
index 22ecce7..eecee7e 100644
--- a/frontend/src/utils/nui.ts
+++ b/frontend/src/utils/nui.ts
@@ -1,4 +1,5 @@
const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone'
+const requestTimeoutMs = 20_000
export type NuiResponse = {
success: boolean
@@ -24,12 +25,15 @@ export async function nuiCall(
undefined,
}
: data
+ const controller = new AbortController()
+ const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs)
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(requestData),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
+ signal: controller.signal,
})
if (!response.ok) {
@@ -41,8 +45,14 @@ export async function nuiCall(
const body = await response.text()
return body ? (JSON.parse(body) as NuiResponse) : { success: true }
} catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error'
+ const message = controller.signal.aborted
+ ? 'request_timeout'
+ : error instanceof Error
+ ? error.message
+ : 'Unknown error'
console.error(`[NUI] ${endpoint} failed:`, error)
return { error: message, success: false }
+ } finally {
+ window.clearTimeout(timeoutId)
}
}
diff --git a/sky_phone/config/payphones.lua b/sky_phone/config/payphones.lua
new file mode 100644
index 0000000..e2a3ecd
--- /dev/null
+++ b/sky_phone/config/payphones.lua
@@ -0,0 +1,248 @@
+-- Server-owned vanilla payphone positions used for authoritative proximity checks.
+-- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895.
+-- Add custom-map booths here; their model must also be listed in Config.Payphones.Props.
+Config.Payphones.Locations = {
+ { model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } },
+ { model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } },
+ { model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } },
+ { model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } },
+ { model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } },
+ { model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } },
+ { model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } },
+ { model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } },
+ { model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } },
+ { model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } },
+ { model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } },
+ { model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } },
+ { model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } },
+ { model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } },
+ { model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } },
+ { model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } },
+ { model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } },
+ { model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } },
+ { model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } },
+ { model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } },
+ { model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } },
+ { model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } },
+ { model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } },
+ { model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } },
+ { model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } },
+ { model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } },
+ { model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } },
+ { model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } },
+ { model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } },
+ { model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } },
+ { model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } },
+ { model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } },
+ { model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } },
+ { model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } },
+ { model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } },
+ { model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } },
+ { model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } },
+ { model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } },
+ { model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } },
+ { model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } },
+ { model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } },
+ { model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } },
+ { model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } },
+ { model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } },
+ { model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } },
+ { model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } },
+ { model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } },
+ { model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } },
+ { model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } },
+ { model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } },
+ { model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } },
+ { model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } },
+ { model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } },
+ { model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } },
+ { model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } },
+ { model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } },
+ { model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } },
+ { model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } },
+ { model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } },
+ { model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } },
+ { model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } },
+ { model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } },
+ { model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } },
+ { model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } },
+ { model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } },
+ { model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } },
+ { model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } },
+ { model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } },
+ { model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } },
+ { model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } },
+ { model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } },
+ { model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } },
+ { model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } },
+ { model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } },
+ { model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } },
+ { model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } },
+ { model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } },
+ { model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } },
+ { model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } },
+ { model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } },
+ { model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } },
+ { model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } },
+ { model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } },
+ { model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } },
+ { model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } },
+ { model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } },
+ { model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } },
+ { model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } },
+ { model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } },
+ { model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } },
+ { model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } },
+ { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } },
+ { model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } },
+ { model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } },
+ { model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } },
+ { model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } },
+ { model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } },
+ { model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } },
+ { model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } },
+ { model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } },
+ { model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } },
+ { model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } },
+ { model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } },
+ { model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } },
+ { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } },
+ { model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } },
+ { model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } },
+ { model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } },
+ { model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } },
+ { model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } },
+ { model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } },
+ { model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } },
+ { model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } },
+ { model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } },
+ { model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } },
+ { model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } },
+ { model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } },
+ { model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } },
+ { model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } },
+ { model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } },
+ { model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } },
+ { model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } },
+ { model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } },
+ { model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } },
+ { model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } },
+ { model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } },
+ { model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } },
+ { model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } },
+ { model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } },
+ { model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } },
+ { model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } },
+ { model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } },
+ { model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } },
+ { model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } },
+ { model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } },
+ { model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } },
+ { model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } },
+ { model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } },
+ { model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } },
+ { model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } },
+ { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } },
+ { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } },
+ { model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } },
+ { model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } },
+ { model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } },
+ { model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } },
+ { model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } },
+ { model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } },
+ { model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } },
+ { model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } },
+ { model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } },
+ { model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } },
+ { model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } },
+ { model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } },
+ { model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } },
+ { model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } },
+ { model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } },
+ { model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } },
+ { model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } },
+ { model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } },
+ { model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } },
+ { model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } },
+ { model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } },
+ { model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } },
+ { model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } },
+ { model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } },
+ { model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } },
+ { model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } },
+ { model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } },
+ { model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } },
+ { model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } },
+ { model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } },
+ { model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } },
+ { model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } },
+ { model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } },
+ { model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } },
+ { model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } },
+ { model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } },
+ { model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } },
+ { model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } },
+ { model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } },
+ { model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } },
+ { model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } },
+ { model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } },
+ { model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } },
+ { model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } },
+ { model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } },
+ { model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } },
+ { model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } },
+ { model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } },
+ { model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } },
+ { model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } },
+ { model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } },
+ { model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } },
+ { model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } },
+ { model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } },
+ { model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } },
+ { model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } },
+ { model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } },
+ { model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } },
+ { model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } },
+ { model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } },
+ { model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } },
+ { model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } },
+ { model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } },
+ { model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } },
+ { model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } },
+ { model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } },
+ { model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } },
+ { model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } },
+ { model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } },
+ { model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } },
+ { model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } },
+ { model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } },
+ { model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } },
+ { model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } },
+ { model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } },
+ { model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } },
+ { model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } },
+ { model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } },
+ { model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } },
+ { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } },
+ { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } },
+ { model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } },
+ { model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } },
+ { model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } },
+ { model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } },
+}
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index ab0e6f8..e5f67e7 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -30,6 +30,7 @@ client_scripts {
'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua',
'source/client/animations.lua',
+ 'source/client/focus.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/client/skyride.lua',
@@ -46,6 +47,7 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
+ 'config/payphones.lua',
'config/companies.lua',
'config/media.lua',
'config/music.lua',
@@ -66,6 +68,7 @@ server_scripts {
'source/server/companies.lua',
'source/server/custom_app_storage.lua',
'source/server/sim.lua',
+ 'source/server/payphones.lua',
'source/server/calls.lua',
'source/server/media.lua',
'source/server/messages.lua',
diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua
index 1cfa566..581d9b0 100644
--- a/sky_phone/source/client/camera.lua
+++ b/sky_phone/source/client/camera.lua
@@ -6,6 +6,10 @@ local ultrawide_fov_multiplier = 2.0
local front_camera_distance = 0.75
local front_camera_height = 0.05
local front_camera_target_height = 0.03
+local unfocused_camera_controls = {
+ 1, -- INPUT_LOOK_LR
+ 2, -- INPUT_LOOK_UD
+}
local camera_state = {
active = false,
enforcing = false,
@@ -16,6 +20,7 @@ local camera_state = {
front_camera_handle = nil,
landscape = false,
ultrawide_camera_handle = nil,
+ applied_nui_focus = true,
nui_focused = true,
previous_ped_view = nil,
previous_radar_hidden = nil,
@@ -145,27 +150,32 @@ local function restore_camera_view()
end
end
-local function set_camera_focus(focused)
- if camera_state.nui_focused == focused then
- return
+local function apply_unfocused_camera_controls()
+ DisableAllControlActions(0)
+ for _, control in ipairs(unfocused_camera_controls) do
+ EnableControlAction(0, control, true)
end
- camera_state.nui_focused = focused
- if focused then
- SetNuiFocus(true, true)
- SetNuiFocusKeepInput(false)
- SendNUIMessage({ type = "camera:focus", data = { focused = true } })
- return
- end
- SetNuiFocus(false, false)
- SetNuiFocusKeepInput(true)
- SendNUIMessage({ type = "camera:focus", data = { focused = false } })
+ DisablePlayerFiring(PlayerId(), true)
+end
+
+local function update_camera_focus_claim()
+ TriggerEvent("sky_phone:client:setCameraFocus", {
+ active = camera_state.active,
+ nuiFocused = camera_state.nui_focused,
+ })
+end
+
+local set_camera_focus
+
+local function watch_unfocused_camera_controls()
if camera_state.focus_watcher then
return
end
camera_state.focus_watcher = true
CreateThread(function()
- while camera_state.active and not camera_state.nui_focused do
- if IsControlJustReleased(0, 22) then
+ while camera_state.active and not camera_state.applied_nui_focus do
+ apply_unfocused_camera_controls()
+ if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
break
end
@@ -175,6 +185,28 @@ local function set_camera_focus(focused)
end)
end
+set_camera_focus = function(focused)
+ camera_state.nui_focused = focused
+ update_camera_focus_claim()
+end
+
+AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
+ if type(data) ~= "table"
+ or type(data.active) ~= "boolean"
+ or type(data.focused) ~= "boolean"
+ or type(data.gameInput) ~= "boolean"
+ then
+ return
+ end
+ if camera_state.applied_nui_focus ~= data.focused then
+ camera_state.applied_nui_focus = data.focused
+ SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
+ end
+ if data.active and data.gameInput then
+ watch_unfocused_camera_controls()
+ end
+end)
+
local function set_camera_active(active)
if camera_state.active == active then
return
@@ -251,11 +283,8 @@ local function set_camera_active(active)
clear_front_camera()
clear_ultrawide_camera()
restore_camera_view()
- if not camera_state.nui_focused then
- camera_state.nui_focused = true
- SetNuiFocusKeepInput(false)
- SetNuiFocus(true, true)
- end
+ camera_state.nui_focused = true
+ update_camera_focus_claim()
TriggerEvent("sky_phone:animation:camera", {
active = false,
front = false,
@@ -325,58 +354,102 @@ local function set_camera_zoom(zoom)
end
RegisterNUICallback("camera:setActive", function(data, cb)
- set_camera_active(data and data.active == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_camera_active(data.active == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFocus", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if camera_state.active then
- set_camera_focus(data and data.focused == true)
+ set_camera_focus(data.focused == true)
end
cb({ success = true })
end)
RegisterNUICallback("camera:setFlash", function(data, cb)
- set_flash_enabled(data and data.enabled == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_flash_enabled(data.enabled == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFacing", function(data, cb)
- set_front_camera(data and data.front == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_front_camera(data.front == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setOrientation", function(data, cb)
- set_camera_landscape(data and data.landscape == true)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ set_camera_landscape(data.landscape == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setZoom", function(data, cb)
- cb({ success = set_camera_zoom(tonumber(data and data.zoom)) })
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ cb({ success = set_camera_zoom(tonumber(data.zoom)) })
end)
RegisterNUICallback("media:requestUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:request-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:request-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:completeUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:complete-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:complete-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:cancelUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:cancel-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:cancel-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:failUpload", function(data, cb)
- TriggerServerEvent("sky_phone:media:fail-upload", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:fail-upload", data)
cb({ success = true })
end)
RegisterNUICallback("gallery:delete", function(data, cb)
- TriggerServerEvent("sky_phone:media:delete", data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ TriggerServerEvent("sky_phone:media:delete", data)
cb({ success = true })
end)
@@ -401,6 +474,5 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
set_flash_enabled(false)
set_camera_active(false)
- SetNuiFocusKeepInput(false)
end
end)
diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua
index 5ecf4eb..4767466 100644
--- a/sky_phone/source/client/crewlink.lua
+++ b/sky_phone/source/client/crewlink.lua
@@ -16,6 +16,10 @@ local function draw_overhead_label(coords, username, role)
end
RegisterNUICallback("crewlink:live", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:live", data)
overhead_members = result and result.success and result.data and result.data.overheadMembers or {}
overhead_expires_at = GetGameTimer() + Config.CrewLink.OverheadRefreshMilliseconds * 2
diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua
new file mode 100644
index 0000000..8252694
--- /dev/null
+++ b/sky_phone/source/client/focus.lua
@@ -0,0 +1,21 @@
+SkyPhoneFocus = {}
+
+function SkyPhoneFocus.Resolve(state)
+ if state.activity_suspended then
+ return { focused = false, keep_input = false }
+ end
+ if state.call_focus then
+ return { focused = true, keep_input = false }
+ end
+ if state.camera_active and not state.camera_nui_focused then
+ return { focused = false, keep_input = true }
+ end
+ return {
+ focused = state.is_open
+ or state.notification_focus
+ or state.payphone_focus
+ or state.sim_picker_open
+ or (state.camera_active and state.camera_nui_focused),
+ keep_input = false,
+ }
+end
diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua
index 6a40adf..cee1d5d 100644
--- a/sky_phone/source/client/garage.lua
+++ b/sky_phone/source/client/garage.lua
@@ -441,6 +441,10 @@ local function run_valet_delivery(order)
end
RegisterNUICallback("garage:valet-request", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if current_valet then
cb({ success = false, error = "valet_active" })
return
diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua
index d54d217..93f3b5b 100644
--- a/sky_phone/source/client/housing.lua
+++ b/sky_phone/source/client/housing.lua
@@ -11,18 +11,18 @@ local function server_prepare(action, data)
end
local function suspend_phone()
- SetNuiFocus(false, false)
+ TriggerEvent("sky_phone:client:setSuspended", true)
TriggerEvent("sky_phone:animation:phone", false)
SendNUIMessage({ type = "app:suspend" })
end
local function resume_phone()
SendNUIMessage({ type = "app:resume" })
- SetNuiFocus(true, true)
+ TriggerEvent("sky_phone:client:setSuspended", false)
TriggerEvent("sky_phone:animation:phone", true)
end
-local function stop_camera()
+local function stop_camera(resume)
local state = camera_state
if not state then
return
@@ -37,7 +37,9 @@ local function stop_camera()
if DoesEntityExist(state.ped) then
FreezeEntityPosition(state.ped, state.frozen)
end
- resume_phone()
+ if resume ~= false then
+ resume_phone()
+ end
end
local function camera_number(value, fallback)
@@ -105,13 +107,17 @@ local function run_camera(provider_name, camera_data)
local maximum_left = camera_number(camera_data.maximumLeft)
local maximum_right = camera_number(camera_data.maximumRight)
local night_vision = camera_state.night_vision
+ local resume_after_camera = false
+ local close_phone_after_camera = false
while camera_state and camera_state.camera == camera do
Wait(0)
DisableAllControlActions(0)
- if IsDisabledControlJustPressed(0, config.ExitControl)
- or IsPedDeadOrDying(ped, true)
- or GetResourceState(provider_name) ~= "started"
- then
+ if IsDisabledControlJustPressed(0, config.ExitControl) then
+ resume_after_camera = true
+ break
+ end
+ if IsPedDeadOrDying(ped, true) or GetResourceState(provider_name) ~= "started" then
+ close_phone_after_camera = true
break
end
@@ -147,12 +153,29 @@ local function run_camera(provider_name, camera_data)
SetNightvision(night_vision)
end
end
- stop_camera()
+ stop_camera(resume_after_camera)
+ if close_phone_after_camera then
+ TriggerEvent("sky_phone:client:forceClose")
+ end
end
-RegisterNUICallback("housing:overview", function(_, cb)
+RegisterNetEvent("sky_phone:device:invalidated", function()
+ stop_camera(false)
+end)
+
+RegisterNUICallback("housing:overview", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
+end)
+
+AddEventHandler("sky_phone:client:nuiReady", function()
+ if camera_state then
+ suspend_phone()
+ end
end)
RegisterNUICallback("housing:key-candidates", function(data, cb)
@@ -161,7 +184,7 @@ RegisterNUICallback("housing:key-candidates", function(data, cb)
return
end
local result = server_prepare("key_candidates", data)
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("housing:command", function(data, cb)
@@ -182,8 +205,8 @@ RegisterNUICallback("housing:command", function(data, cb)
end
local prepared = server_prepare(data.action, data)
- if not prepared or not prepared.success or type(prepared.data) ~= "table" then
- cb(prepared or { success = false, error = "request_failed" })
+ if type(prepared) ~= "table" or not prepared.success or type(prepared.data) ~= "table" then
+ cb(type(prepared) == "table" and prepared or { success = false, error = "request_failed" })
return
end
if data.action == "set_waypoint" then
@@ -214,6 +237,6 @@ end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() and camera_state then
- stop_camera()
+ stop_camera(false)
end
end)
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 8c97ab3..2e88d41 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -1,9 +1,17 @@
local is_open = false
local open_requested = false
local notification_focus = false
+local call_focus = false
+local payphone_focus = false
+local camera_active = false
+local camera_nui_focused = true
local device_payload = nil
local sim_picker_open = false
+local sim_picker_payload = nil
+local active_call_payload = nil
local call_channel = 0
+local nui_generation = 0
+local activity_suspended = false
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
@@ -256,6 +264,46 @@ local function get_locale()
return Locales[Config.Bridge.Locale] or Locales["en"]
end
+local function update_nui_focus()
+ local focus = SkyPhoneFocus.Resolve({
+ activity_suspended = activity_suspended,
+ call_focus = call_focus,
+ camera_active = camera_active,
+ camera_nui_focused = camera_nui_focused,
+ is_open = is_open,
+ notification_focus = notification_focus,
+ payphone_focus = payphone_focus,
+ sim_picker_open = sim_picker_open,
+ })
+ SetNuiFocus(focus.focused, focus.focused)
+ SetNuiFocusKeepInput(focus.keep_input)
+ TriggerEvent("sky_phone:client:cameraFocusApplied", {
+ active = camera_active,
+ focused = focus.focused,
+ gameInput = focus.keep_input,
+ })
+end
+
+AddEventHandler("sky_phone:client:setSuspended", function(suspended)
+ activity_suspended = suspended == true
+ update_nui_focus()
+end)
+
+AddEventHandler("sky_phone:client:setPayphoneFocus", function(focused)
+ payphone_focus = focused == true
+ update_nui_focus()
+end)
+
+AddEventHandler("sky_phone:client:setCameraFocus", function(data)
+ if type(data) ~= "table" or type(data.active) ~= "boolean" or type(data.nuiFocused) ~= "boolean" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid camera focus claim.")
+ return
+ end
+ camera_active = data.active
+ camera_nui_focused = data.nuiFocused
+ update_nui_focus()
+end)
+
local function send_open_message()
if not device_payload then
return
@@ -279,21 +327,31 @@ local function open_phone()
send_open_message()
end
-local function close_phone()
+local function close_phone(close_device_session)
+ local was_requested = open_requested
+ local was_open = is_open
open_requested = false
+ call_focus = false
+ activity_suspended = false
TriggerEvent("sky_phone:animation:phone", false)
- if not is_open then
- return
- end
-
is_open = false
- SkyPhoneApps.SetPhoneOpen(false)
- TriggerEvent("sky_phone:nuiClosed")
- SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
- SendNUIMessage({ type = "app:close" })
- Bridge.Callbacks.Trigger("sky_phone:device:close", {})
+ if was_open then
+ SkyPhoneApps.SetPhoneOpen(false)
+ TriggerEvent("sky_phone:nuiClosed")
+ end
+ update_nui_focus()
+ if was_requested or was_open then
+ SendNUIMessage({ type = "app:close" })
+ if close_device_session ~= false then
+ Bridge.Callbacks.Trigger("sky_phone:device:close", {})
+ end
+ end
end
+AddEventHandler("sky_phone:client:forceClose", function()
+ close_phone()
+end)
+
local function leave_call_voice()
if call_channel == 0 then
return
@@ -337,24 +395,58 @@ RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000)
end)
-RegisterNUICallback("ui:ready", function(_, cb)
+RegisterNUICallback("ui:ready", function(data, cb)
+ if type(data) ~= "table" or data.protocolVersion ~= 1 then
+ cb({ success = false, error = "unsupported_protocol" })
+ return
+
+ end
+ nui_generation = nui_generation + 1
+ -- Browser state is recreated on a CEF reload. A notification focus claim
+ -- cannot survive unless its notification is replayed as part of this handshake.
+ notification_focus = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
- TriggerEvent("sky_phone:client:nuiReady")
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
+ if active_call_payload then
+ SendNUIMessage({ type = "call:state", data = active_call_payload })
+ end
+ if sim_picker_open and sim_picker_payload then
+ SendNUIMessage({ type = "sim:picker", data = sim_picker_payload })
+ else
+ SendNUIMessage({ type = "sim:picker-close" })
+ end
- cb({ success = true })
+ update_nui_focus()
+ TriggerEvent("sky_phone:client:nuiReady", {
+ generation = nui_generation,
+ protocolVersion = 1,
+ })
+
+ cb({
+ success = true,
+ data = {
+ generation = nui_generation,
+ protocolVersion = 1,
+ },
+ })
end)
-RegisterNUICallback("ui:opened", function(_, cb)
+RegisterNUICallback("ui:opened", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
+ SendNUIMessage({ type = "app:close" })
+ update_nui_focus()
cb({ success = false, error = "open_not_requested" })
return
end
@@ -362,29 +454,49 @@ RegisterNUICallback("ui:opened", function(_, cb)
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
notification_focus = false
- SetNuiFocus(true, true)
+ call_focus = false
+ update_nui_focus()
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
-RegisterNUICallback("close", function(_, cb)
+RegisterNUICallback("close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
close_phone()
cb({ success = true })
end)
RegisterNUICallback("notification:focus", function(data, cb)
+ if type(data) ~= "table" or type(data.active) ~= "boolean" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
notification_focus = data.active == true and not is_open
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
+ update_nui_focus()
cb({ success = true })
end)
-RegisterNUICallback("sim:picker-close", function(_, cb)
+RegisterNUICallback("sim:picker-close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
sim_picker_open = false
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
- cb({ success = true })
+ sim_picker_payload = nil
+ update_nui_focus()
+ SendNUIMessage({ type = "sim:picker-close" })
+ local result = Bridge.Callbacks.Trigger("sky_phone:sim:picker-close", {})
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
-RegisterNUICallback("map:getPlayerCoords", function(_, cb)
+RegisterNUICallback("map:getPlayerCoords", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local coords = GetEntityCoords(PlayerPedId())
cb({
success = true,
@@ -439,7 +551,11 @@ local function weather_region(coords)
return "los_santos"
end
-RegisterNUICallback("weather:get", function(_, cb)
+RegisterNUICallback("weather:get", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
local next_weather_hash = GetNextWeatherTypeHashName()
@@ -479,9 +595,13 @@ local function garage_vehicle_kind(model_hash, fallback)
end
RegisterNUICallback("garage:vehicles", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
- if not result or not result.success or type(result.data) ~= "table" then
- cb(result or { success = false, error = "request_failed" })
+ if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
return
end
for _, vehicle in ipairs(result.data.vehicles or {}) do
@@ -505,8 +625,12 @@ end)
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
- if result then
+ if type(result) == "table" then
cb(result)
return
end
@@ -516,6 +640,10 @@ for _, callback_name in ipairs(server_callbacks) do
end
RegisterNetEvent("sky_phone:device:open", function(data)
+ if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
+ return
+ end
Bridge.Debug(
"debug",
"[sky_phone] Client received device open for IMEI %s account_linked=%s.",
@@ -525,19 +653,27 @@ RegisterNetEvent("sky_phone:device:open", function(data)
)
device_payload = data
open_requested = true
+ if is_open then
+ SkyPhoneApps.SendCatalog()
+ SendNUIMessage({ type = "device:updated", data = data })
+ return
+ end
open_phone()
end)
RegisterNetEvent("sky_phone:device:updated", function(data)
+ if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
+ return
+ end
device_payload = data
SendNUIMessage({ type = "device:updated", data = data })
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
- open_requested = false
- device_payload = nil
TriggerEvent("sky_phone:animation:reset")
- close_phone()
+ close_phone(false)
+ device_payload = nil
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
@@ -669,14 +805,20 @@ RegisterNetEvent("sky_phone:flare:message", function(data)
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
+ if type(data) ~= "table" or type(data.number) ~= "string" or type(data.choices) ~= "table" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid SIM picker data.")
+ return
+ end
sim_picker_open = true
- SetNuiFocus(true, true)
+ sim_picker_payload = data
+ update_nui_focus()
SendNUIMessage({ type = "sim:picker", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker-close", function()
sim_picker_open = false
- SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
+ sim_picker_payload = nil
+ update_nui_focus()
SendNUIMessage({ type = "sim:picker-close" })
end)
@@ -752,13 +894,35 @@ RegisterNetEvent("sky_phone:darkchat:new", function(data)
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
- notification_focus = true
- SetNuiFocus(true, true)
+ if type(data) ~= "table" or type(data.id) ~= "string" or data.state ~= "ringing" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid incoming call data.")
+ return
+ end
+ active_call_payload = data
+ call_focus = true
+ update_nui_focus()
TriggerEvent("sky_phone:animation:call", data)
SendNUIMessage({ type = "call:incoming", data = data })
end)
RegisterNetEvent("sky_phone:call:state", function(data)
+ if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
+ Bridge.Debug("error", "[sky_phone] Rejected invalid call state data.")
+ return
+ end
+ if data.state == "ringing" or data.state == "connected" then
+ active_call_payload = data
+ end
+ if data.state ~= "ringing" then
+ local focus_changed = call_focus
+ call_focus = false
+ if focus_changed then
+ update_nui_focus()
+ end
+ end
+ if data.state ~= "ringing" and data.state ~= "connected" then
+ active_call_payload = nil
+ end
if data.state == "connected" and data.channel then
if not join_call_voice(data.channel) then
TriggerEvent("sky_phone:device:error", "voice_unavailable")
@@ -784,9 +948,19 @@ AddEventHandler("onResourceStop", function(resource_name)
return
end
- if is_open or notification_focus then
- SetNuiFocus(false, false)
- end
+ is_open = false
+ open_requested = false
+ notification_focus = false
+ call_focus = false
+ payphone_focus = false
+ camera_active = false
+ camera_nui_focused = true
+ sim_picker_open = false
+ sim_picker_payload = nil
+ active_call_payload = nil
+ activity_suspended = false
+ SetNuiFocusKeepInput(false)
+ SetNuiFocus(false, false)
TriggerEvent("sky_phone:animation:reset")
leave_call_voice()
diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua
index d3fb3b4..d59b569 100644
--- a/sky_phone/source/client/payphones.lua
+++ b/sky_phone/source/client/payphones.lua
@@ -6,6 +6,7 @@ local active_call_state = nil
local active_call_number = nil
local active_call_elapsed_seconds = 0
local active_call_elapsed_updated_at = 0
+local active_call_payload = nil
local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
@@ -360,11 +361,20 @@ local function booth_payload(booth)
}
end
+local function payphone_open_payload()
+ return {
+ currency = Config.Payphones.Currency,
+ maxNumberLength = Config.Sim.NumberLength,
+ pricePerSecond = Config.Payphones.PricePerSecond,
+ locales = get_locale().Nui.Payphone,
+ }
+end
+
local function close_payphone()
local was_open = payphone_open
payphone_open = false
if was_open then
- SetNuiFocus(false, false)
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
SendNUIMessage({ type = "payphone:close" })
end
if not active_call_id then
@@ -418,6 +428,7 @@ local function apply_active_call_state(data)
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
end
+ active_call_payload = data
end
local function clear_active_call_state()
@@ -426,6 +437,7 @@ local function clear_active_call_state()
active_call_number = nil
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
+ active_call_payload = nil
hangup_requested = false
end
@@ -435,48 +447,51 @@ local function open_payphone(booth)
end
active_booth = booth
payphone_open = true
- SetNuiFocus(true, true)
+ TriggerEvent("sky_phone:client:setPayphoneFocus", true)
SendNUIMessage({
type = "payphone:open",
- data = {
- currency = Config.Payphones.Currency,
- maxNumberLength = Config.Sim.NumberLength,
- pricePerSecond = Config.Payphones.PricePerSecond,
- locales = get_locale().Nui.Payphone,
- },
+ data = payphone_open_payload(),
})
end
RegisterNUICallback("payphone:dial", function(data, cb)
- if not payphone_open or not active_booth or active_call_id then
+ if type(data) ~= "table" or not payphone_open or not active_booth or active_call_id then
cb({ success = false, error = "invalid_request" })
return
end
local payload = booth_payload(active_booth)
- payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil
+ payload.phoneNumber = data.phoneNumber
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload)
- local call_started = result and result.success and result.data
+ local call_started = type(result) == "table" and result.success and type(result.data) == "table"
and (result.data.state == "ringing" or result.data.state == "connected")
if call_started then
apply_active_call_state(result.data)
end
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
if call_started then
close_payphone()
start_call_visuals()
end
end)
-RegisterNUICallback("payphone:hangup", function(_, cb)
+RegisterNUICallback("payphone:hangup", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if not active_call_id then
cb({ success = false, error = "call_not_found" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
- cb(result or { success = false, error = "request_failed" })
+ cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
-RegisterNUICallback("payphone:close", function(_, cb)
+RegisterNUICallback("payphone:close", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
if active_call_id then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
@@ -485,7 +500,7 @@ RegisterNUICallback("payphone:close", function(_, cb)
end)
RegisterNetEvent("sky_phone:payphone:state", function(data)
- if type(data) ~= "table" then
+ if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
return
end
if data.state == "ringing" or data.state == "connected" then
@@ -506,6 +521,24 @@ RegisterNetEvent("sky_phone:payphone:state", function(data)
SendNUIMessage({ type = "payphone:state", data = data })
end)
+AddEventHandler("sky_phone:client:nuiReady", function()
+ if payphone_open then
+ TriggerEvent("sky_phone:client:setPayphoneFocus", true)
+ SendNUIMessage({ type = "payphone:open", data = payphone_open_payload() })
+ else
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
+ SendNUIMessage({ type = "payphone:close" })
+ end
+ if active_call_payload then
+ local payload = {}
+ for key, value in pairs(active_call_payload) do
+ payload[key] = value
+ end
+ payload.elapsedSeconds = current_call_elapsed_seconds()
+ SendNUIMessage({ type = "payphone:state", data = payload })
+ end
+end)
+
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then
@@ -625,9 +658,7 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
- if payphone_open then
- SetNuiFocus(false, false)
- end
+ TriggerEvent("sky_phone:client:setPayphoneFocus", false)
leave_call_voice()
stop_call_visuals()
clear_active_call_state()
diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua
index 0132f7d..2b087cf 100644
--- a/sky_phone/source/client/radio.lua
+++ b/sky_phone/source/client/radio.lua
@@ -120,7 +120,10 @@ local function join_radio(primary, secondary)
return approved
end
- local data = approved.data or {}
+ if type(approved.data) ~= "table" then
+ return { success = false, error = "request_failed" }
+ end
+ local data = approved.data
local approved_primary = tonumber(data.frequency) or 0
local approved_secondary = tonumber(data.secondaryFrequency) or 0
if not Bridge.Radio.Join(approved_primary, approved_secondary) then
@@ -148,27 +151,41 @@ local function leave_radio()
return request("disconnect")
end
-RegisterNUICallback("radio:get", function(_, cb)
+RegisterNUICallback("radio:get", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = request("get")
- if result.success then
+ if result.success and type(result.data) == "table" then
apply_server_state(result.data)
result.data.volume = current_volume
result.data.provider = Bridge.Radio.GetProvider()
result.data.secondarySupported = Bridge.Radio.SupportsSecondary()
+ elseif result.success then
+ result = { success = false, error = "request_failed" }
end
cb(result)
end)
RegisterNUICallback("radio:connect", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(join_radio(data.frequency, data.secondaryFrequency))
end)
-RegisterNUICallback("radio:disconnect", function(_, cb)
+RegisterNUICallback("radio:disconnect", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(leave_radio())
end)
RegisterNUICallback("radio:set-volume", function(data, cb)
- local volume = tonumber(data.volume)
+ local volume = type(data) == "table" and tonumber(data.volume) or nil
if not volume then
cb({ success = false, error = "invalid_volume" })
return
@@ -179,6 +196,10 @@ RegisterNUICallback("radio:set-volume", function(data, cb)
end)
RegisterNUICallback("radio:save-settings", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
local result = request("save-settings", data)
if result.success then
apply_server_state({ settings = result.data })
@@ -187,14 +208,25 @@ RegisterNUICallback("radio:save-settings", function(data, cb)
end)
RegisterNUICallback("radio:save-badge", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(request("save-badge", data))
end)
RegisterNUICallback("radio:save-display-name", function(data, cb)
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
cb(request("save-display-name", data))
end)
RegisterNetEvent("sky_phone:radio:members", function(data)
+ if type(data) ~= "table" then
+ return
+ end
local frequency = tonumber(data.frequency)
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
if not channel_id then
diff --git a/sky_phone/source/client/skyride.lua b/sky_phone/source/client/skyride.lua
index abf5862..42240c4 100644
--- a/sky_phone/source/client/skyride.lua
+++ b/sky_phone/source/client/skyride.lua
@@ -88,7 +88,11 @@ end
for index = 1, #server_callbacks do
local callback_name = server_callbacks[index]
RegisterNUICallback(callback_name, function(data, cb)
- local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {})
+ if type(data) ~= "table" then
+ cb({ success = false, error = "invalid_request" })
+ return
+ end
+ local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
if not result then
cb({ success = false, error = "request_failed" })
return
diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua
index 537306c..b81e731 100644
--- a/sky_phone/source/server/calls.lua
+++ b/sky_phone/source/server/calls.lua
@@ -1024,31 +1024,45 @@ end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
- payphone_models[model_name] = true
+ if type(model_name) == "string" then
+ payphone_models[model_name] = true
+ end
end
-local function valid_payphone_position(source, data)
- if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then
- return nil
- end
- local x = tonumber(data.coords.x)
- local y = tonumber(data.coords.y)
- local z = tonumber(data.coords.z)
- if not x or not y or not z or x ~= x or y ~= y or z ~= z
- or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0
- then
- return nil
- end
+local payphone_locations, rejected_payphone_locations = SkyPhonePayphones.ValidateLocations(
+ Config.Payphones.Locations,
+ payphone_models
+)
+if Config.Payphones.Enabled and #payphone_locations == 0 then
+ Bridge.Debug(
+ "error",
+ "[sky_phone] Payphones are enabled, but no valid server-owned locations are configured; payphone calls will be rejected.",
+ { always = true }
+ )
+elseif Config.Payphones.Enabled and rejected_payphone_locations > 0 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Ignored %s invalid server-owned payphone location(s).",
+ rejected_payphone_locations,
+ { always = true }
+ )
+end
+
+local function valid_payphone_position(source)
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
return nil
end
local player_coords = GetEntityCoords(ped)
- local booth_coords = vector3(x, y, z)
- if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then
+ local location = SkyPhonePayphones.FindNearest(
+ payphone_locations,
+ player_coords,
+ Config.Payphones.ServerValidationDistance
+ )
+ if not location then
return nil
end
- return booth_coords, data.model
+ return vector3(location.coords.x, location.coords.y, location.coords.z), location.model
end
local function payphone_terminal(number, state)
@@ -1064,10 +1078,13 @@ local function payphone_terminal(number, state)
end
Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
- local booth_coords, booth_model = valid_payphone_position(source, data)
+ local booth_coords, booth_model = valid_payphone_position(source)
if not booth_coords then
return { success = false, error = "invalid_payphone" }
end
diff --git a/sky_phone/source/server/payphones.lua b/sky_phone/source/server/payphones.lua
new file mode 100644
index 0000000..3a1fa47
--- /dev/null
+++ b/sky_phone/source/server/payphones.lua
@@ -0,0 +1,105 @@
+SkyPhonePayphones = {}
+
+local maximum_horizontal_coordinate = 10000.0
+local maximum_vertical_coordinate = 2000.0
+
+local function finite_number(value)
+ if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
+ return nil
+ end
+ return value
+end
+
+local function normalize_coordinates(value, allow_vector)
+ local value_type = type(value)
+ if value_type ~= "table" and (not allow_vector or value_type ~= "vector3") then
+ return nil
+ end
+
+ local x = finite_number(value.x)
+ local y = finite_number(value.y)
+ local z = finite_number(value.z)
+ if not x or not y or not z
+ or math.abs(x) > maximum_horizontal_coordinate
+ or math.abs(y) > maximum_horizontal_coordinate
+ or math.abs(z) > maximum_vertical_coordinate
+ then
+ return nil
+ end
+
+ return { x = x, y = y, z = z }
+end
+
+local function normalize_location(location, allowed_models)
+ if type(location) ~= "table" or type(location.model) ~= "string" or not allowed_models[location.model] then
+ return nil
+ end
+
+ local coords = normalize_coordinates(location.coords, false)
+ if not coords then
+ return nil
+ end
+
+ return {
+ model = location.model,
+ coords = coords,
+ }
+end
+
+function SkyPhonePayphones.ValidateLocations(locations, allowed_models)
+ if type(locations) ~= "table" or type(allowed_models) ~= "table" then
+ return {}, 0
+ end
+
+ local validated = {}
+ local rejected = 0
+ for index, location in pairs(locations) do
+ local valid_index = type(index) == "number" and index >= 1 and index % 1 == 0
+ local normalized = valid_index and normalize_location(location, allowed_models) or nil
+ if normalized then
+ normalized.index = index
+ validated[#validated + 1] = normalized
+ else
+ rejected = rejected + 1
+ end
+ end
+
+ table.sort(validated, function(left, right)
+ return left.index < right.index
+ end)
+ for index = 1, #validated do
+ validated[index].index = nil
+ end
+
+ return validated, rejected
+end
+
+function SkyPhonePayphones.FindNearest(locations, player_coords, maximum_distance)
+ if type(locations) ~= "table" then
+ return nil
+ end
+
+ local coords = normalize_coordinates(player_coords, true)
+ local distance = finite_number(maximum_distance)
+ if not coords or not distance or distance <= 0 then
+ return nil
+ end
+
+ local nearest = nil
+ local nearest_distance_squared = distance * distance
+ for index = 1, #locations do
+ local location = locations[index]
+ if type(location) == "table" and type(location.coords) == "table" then
+ local dx = coords.x - location.coords.x
+ local dy = coords.y - location.coords.y
+ local dz = coords.z - location.coords.z
+ local distance_squared = dx * dx + dy * dy + dz * dz
+ if distance_squared <= nearest_distance_squared then
+ nearest = location
+ nearest_distance_squared = distance_squared
+ end
+ end
+ end
+
+ return nearest
+end
diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua
index 91d0a0f..97c003d 100644
--- a/sky_phone/source/server/phone.lua
+++ b/sky_phone/source/server/phone.lua
@@ -914,15 +914,20 @@ function SkyPhone.OpenDeviceForCall(source, imei)
return false
end
local security = load_device_security(imei)
- if sessions[source] and sessions[source].imei ~= imei then
+ local existing_session = sessions[source]
+ if existing_session and existing_session.imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
- sessions[source] = {
- imei = imei,
- slot = matches[1].slot,
- token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
- unlocked = security == nil,
- }
+ if existing_session and existing_session.imei == imei then
+ existing_session.slot = matches[1].slot
+ else
+ sessions[source] = {
+ imei = imei,
+ slot = matches[1].slot,
+ token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
+ unlocked = security == nil,
+ }
+ end
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
@@ -1075,7 +1080,13 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
if not session then
return error_response
end
- if type(data) ~= "table" or not allowed_device_namespaces[data.namespace] then
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
+ if data.imei ~= session.imei or data.sessionToken ~= session.token then
+ return { success = false, error = "stale_session" }
+ end
+ if not allowed_device_namespaces[data.namespace] then
return { success = false, error = "invalid_namespace" }
end
diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua
index 34bde46..fe6f3c4 100644
--- a/sky_phone/source/server/sim.lua
+++ b/sky_phone/source/server/sim.lua
@@ -406,6 +406,14 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
return insert_sim(source, data.imei, data.confirmed == true)
end)
+Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source)
+ if operation_locks[source] then
+ return { success = false, error = "operation_in_progress" }
+ end
+ pending_insertions[source] = nil
+ return { success = true }
+end)
+
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
diff --git a/tests/client_focus.lua b/tests/client_focus.lua
new file mode 100644
index 0000000..f70ae6c
--- /dev/null
+++ b/tests/client_focus.lua
@@ -0,0 +1,67 @@
+dofile("sky_phone/source/client/focus.lua")
+
+local function resolve(overrides)
+ local state = {
+ activity_suspended = false,
+ call_focus = false,
+ camera_active = false,
+ camera_nui_focused = true,
+ is_open = false,
+ notification_focus = false,
+ payphone_focus = false,
+ sim_picker_open = false,
+ }
+ for key, value in pairs(overrides or {}) do
+ state[key] = value
+ end
+ return SkyPhoneFocus.Resolve(state)
+end
+
+local idle = resolve()
+assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override")
+
+local minimized_call = resolve()
+assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused")
+
+local incoming_call = resolve({ call_focus = true })
+assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI")
+
+local camera_game_input = resolve({
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ not camera_game_input.focused and camera_game_input.keep_input,
+ "unfocused camera must own game input over the open phone"
+)
+
+local camera_interrupted_by_call = resolve({
+ call_focus = true,
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input,
+ "incoming call attention must override unfocused camera input"
+)
+
+local camera_after_connected_call = resolve({
+ call_focus = false,
+ camera_active = true,
+ camera_nui_focused = false,
+ is_open = true,
+})
+assert(
+ not camera_after_connected_call.focused and camera_after_connected_call.keep_input,
+ "connected call without an attention claim must restore unfocused camera input"
+)
+
+local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false })
+assert(payphone_closed_behind_phone.focused, "releasing payphone focus must not clear mobile phone focus")
+
+local suspended = resolve({ activity_suspended = true, call_focus = true, is_open = true })
+assert(not suspended.focused and not suspended.keep_input, "suspended activities must mask every focus claim")
+
+print("Client focus tests passed")
diff --git a/tests/server_payphones.lua b/tests/server_payphones.lua
new file mode 100644
index 0000000..1798a08
--- /dev/null
+++ b/tests/server_payphones.lua
@@ -0,0 +1,39 @@
+dofile("sky_phone/source/server/payphones.lua")
+
+local allowed_models = {
+ prop_phonebox_01a = true,
+ prop_phonebox_04 = true,
+}
+
+local locations, rejected = SkyPhonePayphones.ValidateLocations({
+ { model = "prop_phonebox_01a", coords = { x = 100.0, y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_04", coords = { x = 105.0, y = 200.0, z = 30.0 } },
+ { model = "not_allowed", coords = { x = 100.0, y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_01a", coords = { x = "100", y = 200.0, z = 30.0 } },
+ { model = "prop_phonebox_01a", coords = { x = 10001.0, y = 200.0, z = 30.0 } },
+ "malformed",
+}, allowed_models)
+
+assert(#locations == 2, "only strictly valid configured locations must be accepted")
+assert(rejected == 4, "every malformed or disallowed configured location must be reported")
+
+local first = SkyPhonePayphones.FindNearest(locations, { x = 101.0, y = 200.0, z = 30.0 }, 3.0)
+assert(first and first.model == "prop_phonebox_01a", "nearest configured booth must be selected")
+
+local second = SkyPhonePayphones.FindNearest(locations, { x = 104.0, y = 200.0, z = 30.0 }, 3.0)
+assert(second and second.model == "prop_phonebox_04", "another configured booth must be selected by proximity")
+
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = 0.0, y = 0.0, z = 0.0 }, 3.0),
+ "a player away from every configured booth must be rejected"
+)
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = "100", y = 200.0, z = 30.0 }, 3.0),
+ "malformed player coordinates must be rejected"
+)
+assert(
+ not SkyPhonePayphones.FindNearest(locations, { x = 100.0, y = 200.0, z = 30.0 }, 0.0),
+ "an invalid validation distance must be rejected"
+)
+
+print("Server payphone validation tests passed")
From ff7e00596b114ebd27dcd217e0cad1b493b7cd39 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Wed, 12 Aug 2026 18:59:14 +0200
Subject: [PATCH 39/63] FIX - stabilize CEF media capture and uploads
---
.../components/MessageAttachmentBubble.vue | 41 ++-
frontend/src/components/PhoneMediaCapture.vue | 236 ++++++++++++----
frontend/src/utils/gameView.test.ts | 93 ++++++-
frontend/src/utils/gameView.ts | 255 ++++++++++++------
frontend/src/utils/mediaRecorder.test.ts | 66 +++++
frontend/src/utils/mediaRecorder.ts | 62 +++++
frontend/src/views/apps/CameraApp.vue | 21 +-
frontend/src/views/apps/FlipTokApp.vue | 111 +++++++-
frontend/src/views/apps/GalleryApp.vue | 71 ++++-
frontend/testserver/index.cjs | 3 +-
sky_phone/config/media.lua | 4 +-
sky_phone/source/server/darkchat.lua | 8 +-
sky_phone/source/server/db_migrate.lua | 32 +++
sky_phone/source/server/media.lua | 77 ++++--
sky_phone/source/server/messages.lua | 11 +-
15 files changed, 912 insertions(+), 179 deletions(-)
create mode 100644 frontend/src/utils/mediaRecorder.test.ts
create mode 100644 frontend/src/utils/mediaRecorder.ts
diff --git a/frontend/src/components/MessageAttachmentBubble.vue b/frontend/src/components/MessageAttachmentBubble.vue
index 729eaed..3db3d83 100644
--- a/frontend/src/components/MessageAttachmentBubble.vue
+++ b/frontend/src/components/MessageAttachmentBubble.vue
@@ -1,7 +1,8 @@
@@ -234,10 +257,15 @@ onBeforeUnmount(() => {
type="button"
:aria-label="getPhoneAppLabel(app, phone.t)"
:aria-disabled="!app.route"
+ :aria-keyshortcuts="
+ editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
+ "
@click="launch"
@contextmenu.prevent
+ @keydown="onKeydown"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
+ @lostpointercapture="cancelPointerDrag"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
diff --git a/frontend/src/components/DarkChatSelect.vue b/frontend/src/components/DarkChatSelect.vue
index 804ae57..baa33d1 100644
--- a/frontend/src/components/DarkChatSelect.vue
+++ b/frontend/src/components/DarkChatSelect.vue
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
}
function closeFromEscape(event: KeyboardEvent): void {
- if (event.key === 'Escape') opened.value = false
+ if (event.key !== 'Escape' || !opened.value) return
+ event.preventDefault()
+ event.stopPropagation()
+ opened.value = false
}
onMounted(() => {
diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue
index 9e83d30..48da343 100644
--- a/frontend/src/components/EasyShareSheet.vue
+++ b/frontend/src/components/EasyShareSheet.vue
@@ -9,7 +9,7 @@ import {
UserRound,
X,
} from 'lucide-vue-next'
-import { computed, ref } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
@@ -30,6 +30,7 @@ import {
easyShareDestinationAppIds,
openEasySharePayload,
} from '@/utils/easyshare'
+import { consumeEscape } from '@/utils/keyboard'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
@@ -113,11 +114,8 @@ const shareApps = computed(() =>
return app ? [{ app, id }] : []
}),
)
-const sheetStyle = computed(() => ({
- transform: easyShare.opened
- ? `translateY(calc(-100% + ${dragOffset.value}px))`
- : undefined,
- transitionDuration: dragging.value ? '0ms' : undefined,
+const hostStyle = computed(() => ({
+ '--easyshare-drag-offset': `${dragOffset.value}px`,
}))
function label(key: string, params?: Record): string {
@@ -155,6 +153,11 @@ function close(): void {
easyShare.close()
}
+function onKeydown(event: KeyboardEvent): void {
+ if (!easyShare.opened || !consumeEscape(event)) return
+ close()
+}
+
function beginDrag(event: PointerEvent): void {
if (!easyShare.opened || event.button !== 0) return
dragPointerId = event.pointerId
@@ -240,17 +243,22 @@ async function openTransfer(transfer: EasyShareTransfer): Promise {
close()
await openEasySharePayload(router, transfer.payload)
}
+
+onMounted(() => window.addEventListener('keydown', onKeydown, true))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true))
@@ -407,7 +415,9 @@ async function openTransfer(transfer: EasyShareTransfer): Promise {
diff --git a/frontend/src/views/apps/FlareApp.vue b/frontend/src/views/apps/FlareApp.vue
index 6eda1a4..f7dbca5 100644
--- a/frontend/src/views/apps/FlareApp.vue
+++ b/frontend/src/views/apps/FlareApp.vue
@@ -85,6 +85,7 @@ import type { PhoneMedia } from '@/types/media'
import type { EasySharePayload } from '@/types/easyshare'
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
+import { handleEnterAction } from '@/utils/keyboard'
type FlareTab = 'discover' | 'explore' | 'likes' | 'matches' | 'profile'
type ExploreMode = 'all' | 'dates' | 'friends' | 'longTerm'
@@ -1145,7 +1146,7 @@ onBeforeUnmount(() => {
:value="draft"
:disabled="flare.sending"
@input="draft = eventValue($event)"
- @keydown.enter.exact.prevent="sendMessage"
+ @keydown.enter.exact="handleEnterAction($event, sendMessage)"
>
@@ -1861,7 +1862,7 @@ onBeforeUnmount(() => {
:aria-modal="choiceOpened ? 'true' : undefined"
aria-labelledby="flare-choice-sheet-title"
:inert="!choiceOpened"
- @keydown.esc="closeChoice"
+ @keydown.esc.stop.prevent="closeChoice"
>
diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue
new file mode 100644
index 0000000..c90180b
--- /dev/null
+++ b/frontend/src/components/account/AppProfileAuth.vue
@@ -0,0 +1,391 @@
+
+
+
+
+
+
+
+
+
+ {{ loginLabel }}
+
+
+ {{ registerLabel }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ galleryLabel }}
+
+
+ {{ cameraLabel }}
+
+
+
+
+
+
+
+ {{ emailLabel }}
+ {{ email }}
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+ {{ mode === 'login' ? loginLabel : registerLabel }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/account/IfruitAppAuth.vue b/frontend/src/components/account/IfruitAppAuth.vue
new file mode 100644
index 0000000..a206ce9
--- /dev/null
+++ b/frontend/src/components/account/IfruitAppAuth.vue
@@ -0,0 +1,191 @@
+
+
+
+
+ {{ phone.t('Common.appAuth.eyebrow') }}
+ {{ phone.t('Common.appAuth.title', { app: appName }) }}
+ {{ phone.t('Common.appAuth.body', { app: appName }) }}
+
+
+
+ {{ phone.t('Common.appAuth.login') }}
+
+
+ {{ phone.t('Common.appAuth.register') }}
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+ {{ phone.t(mode === 'login' ? 'Common.appAuth.loginAction' : 'Common.appAuth.registerAction') }}
+
+
+
+
+
+
diff --git a/frontend/src/components/citymarkt/CityMarktAuth.vue b/frontend/src/components/citymarkt/CityMarktAuth.vue
new file mode 100644
index 0000000..abd7caa
--- /dev/null
+++ b/frontend/src/components/citymarkt/CityMarktAuth.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
diff --git a/frontend/src/stores/account.test.ts b/frontend/src/stores/account.test.ts
index ed32192..b8eeb62 100644
--- a/frontend/src/stores/account.test.ts
+++ b/frontend/src/stores/account.test.ts
@@ -58,6 +58,19 @@ describe('account store', () => {
expect(account.devices).toEqual(devices)
})
+ it('clears the linked account after logout', async () => {
+ mockNuiCall.mockResolvedValueOnce({ success: true })
+
+ const account = useAccountStore()
+ account.hydrate({ devices, email: 'alex@ifruit.com' })
+ const success = await account.logout()
+
+ expect(success).toBe(true)
+ expect(account.email).toBe('')
+ expect(account.devices).toEqual([])
+ expect(mockNuiCall).toHaveBeenCalledWith('account:logout')
+ })
+
it('clears account state after a factory reset', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
diff --git a/frontend/src/stores/app-auth.test.ts b/frontend/src/stores/app-auth.test.ts
new file mode 100644
index 0000000..b275e8f
--- /dev/null
+++ b/frontend/src/stores/app-auth.test.ts
@@ -0,0 +1,53 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const saveDeviceNamespace = vi.fn()
+
+vi.mock('@/stores/phone', () => ({
+ usePhoneStore: () => ({ saveDeviceNamespace }),
+}))
+
+import { useAppAuthStore } from '@/stores/app-auth'
+
+describe('app auth store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ saveDeviceNamespace.mockReset()
+ })
+
+ it('keeps each app session independent', () => {
+ const auth = useAppAuthStore()
+ auth.hydrate(
+ {
+ accountEmail: 'demo@ifruit.com',
+ signedIn: ['citymarkt', 'feather'],
+ version: 1,
+ },
+ 'demo@ifruit.com',
+ )
+
+ auth.signOut('citymarkt')
+
+ expect(auth.isSignedIn('citymarkt')).toBe(false)
+ expect(auth.isSignedIn('feather')).toBe(true)
+ expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
+ accountEmail: 'demo@ifruit.com',
+ signedIn: ['feather'],
+ version: 1,
+ })
+ })
+
+ it('does not restore sessions belonging to another iFruit account', () => {
+ const auth = useAppAuthStore()
+ auth.hydrate(
+ {
+ accountEmail: 'old@ifruit.com',
+ signedIn: ['citymarkt'],
+ version: 1,
+ },
+ 'new@ifruit.com',
+ )
+
+ expect(auth.isSignedIn('citymarkt')).toBe(false)
+ })
+})
diff --git a/frontend/src/stores/app-auth.ts b/frontend/src/stores/app-auth.ts
new file mode 100644
index 0000000..753bdaa
--- /dev/null
+++ b/frontend/src/stores/app-auth.ts
@@ -0,0 +1,78 @@
+import { defineStore } from 'pinia'
+
+import { usePhoneStore } from '@/stores/phone'
+
+export const APP_AUTH_IDS = [
+ 'citymarkt',
+ 'local-pages',
+ 'feather',
+ 'crewlink',
+] as const
+
+export type AppAuthId = (typeof APP_AUTH_IDS)[number]
+
+type PersistedAppAuth = {
+ accountEmail: string
+ signedIn: AppAuthId[]
+ version: 1
+}
+
+function emptySessions(): Record {
+ return {
+ citymarkt: false,
+ 'local-pages': false,
+ feather: false,
+ crewlink: false,
+ }
+}
+
+export const useAppAuthStore = defineStore('app-auth', {
+ state: () => ({
+ accountEmail: '',
+ sessions: emptySessions(),
+ }),
+ actions: {
+ hydrate(payload: unknown, accountEmail: string): void {
+ this.accountEmail = accountEmail
+ this.sessions = emptySessions()
+ if (!accountEmail || !payload || typeof payload !== 'object') return
+
+ const data = payload as Partial
+ if (
+ data.version !== 1 ||
+ data.accountEmail !== accountEmail ||
+ !Array.isArray(data.signedIn)
+ )
+ return
+
+ for (const appId of data.signedIn) {
+ if (APP_AUTH_IDS.includes(appId)) this.sessions[appId] = true
+ }
+ },
+ isSignedIn(appId: AppAuthId): boolean {
+ return Boolean(this.accountEmail && this.sessions[appId])
+ },
+ signIn(appId: AppAuthId, accountEmail: string): void {
+ if (this.accountEmail !== accountEmail) this.sessions = emptySessions()
+ this.accountEmail = accountEmail
+ this.sessions[appId] = true
+ this.persist()
+ },
+ signOut(appId: AppAuthId): void {
+ this.sessions[appId] = false
+ this.persist()
+ },
+ clear(): void {
+ this.accountEmail = ''
+ this.sessions = emptySessions()
+ this.persist()
+ },
+ persist(): void {
+ usePhoneStore().saveDeviceNamespace('appAuth', {
+ accountEmail: this.accountEmail,
+ signedIn: APP_AUTH_IDS.filter((appId) => this.sessions[appId]),
+ version: 1,
+ } satisfies PersistedAppAuth)
+ },
+ },
+})
diff --git a/frontend/src/stores/crewlink.ts b/frontend/src/stores/crewlink.ts
index 0b8ea0e..637a621 100644
--- a/frontend/src/stores/crewlink.ts
+++ b/frontend/src/stores/crewlink.ts
@@ -62,19 +62,28 @@ export const useCrewLinkStore = defineStore('crewlink', {
}
return response
},
- createProfile(username: string): Promise> {
- return this.request('crewlink:create-profile', { username })
+ createProfile(
+ username: string,
+ avatarMediaId = 0,
+ ): Promise> {
+ return this.request('crewlink:create-profile', {
+ avatarMediaId,
+ username,
+ })
},
updateProfile(
username: string,
mapVisible: boolean,
overheadVisible: boolean,
+ avatarMediaId?: number | null,
): Promise> {
- return this.request('crewlink:update-profile', {
+ const data: Record = {
mapVisible,
overheadVisible,
username,
- })
+ }
+ if (avatarMediaId !== undefined) data.avatarMediaId = avatarMediaId
+ return this.request('crewlink:update-profile', data)
},
createGroup(
name: string,
@@ -107,17 +116,15 @@ export const useCrewLinkStore = defineStore('crewlink', {
return this.request('crewlink:join-code', { code })
},
rotateCode(groupId: string): Promise> {
- return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId })
+ return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', {
+ groupId,
+ })
},
nearby(): Promise> {
return nuiCall('crewlink:nearby')
},
inviteNearby(targetSource: number): Promise {
- return this.request(
- 'crewlink:invite-nearby',
- { targetSource },
- false,
- )
+ return this.request('crewlink:invite-nearby', { targetSource }, false)
},
respondInvite(
invitationId: string,
diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts
new file mode 100644
index 0000000..e3ecfb8
--- /dev/null
+++ b/frontend/src/stores/phone-locales.test.ts
@@ -0,0 +1,32 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { usePhoneStore } from '@/stores/phone'
+
+describe('phone locale fallback', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ matchMedia: vi.fn(() => ({ matches: false })),
+ })
+ setActivePinia(createPinia())
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('keeps CityMarkt profile copy translated with a partial server locale', () => {
+ const phone = usePhoneStore()
+ phone.open({ locales: { Apps: { citymarkt: { name: 'CityMarkt' } } } })
+
+ expect(phone.t('Apps.citymarkt.editProfile')).toBe('Edit profile')
+ expect(phone.t('Apps.citymarkt.profileIntro')).toBe(
+ 'Your iFruit email stays linked to this profile.',
+ )
+ expect(phone.t('Apps.citymarkt.saveProfile')).toBe('Save profile')
+ expect(phone.t('Apps.citymarkt.addFavorite')).toBe('Add to favorites')
+ expect(phone.t('Apps.citymarkt.removeFavorite')).toBe(
+ 'Remove from favorites',
+ )
+ })
+})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 79a9fc9..2b40dea 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -421,6 +421,26 @@ const defaultLocales: LocaleTree = {
signInBody:
'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.',
openSettings: 'Open iFruit Settings',
+ authEyebrow: 'Private crew network',
+ authTitle: 'Welcome to CrewLink',
+ authBody:
+ 'Your iFruit email is linked automatically. Use your CrewLink username to continue.',
+ login: 'Log in',
+ register: 'Register',
+ ifruitEmail: 'iFruit address',
+ gallery: 'Gallery',
+ camera: 'Camera',
+ backToLogin: 'Back to CrewLink login',
+ authErrors: {
+ no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
+ invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.',
+ profile_not_found: 'No CrewLink profile exists for this iFruit email.',
+ profile_exists: 'This iFruit email already has a CrewLink profile.',
+ username_taken: 'That CrewLink username is already taken.',
+ invalid_profile_image: 'Choose a valid photo from this phone.',
+ rate_limited: 'Too many attempts. Try again shortly.',
+ request_failed: 'CrewLink could not complete the request.',
+ },
welcomeEyebrow: 'Your crew. One signal.',
welcomeTitle: 'Find your people',
welcomeBody:
@@ -553,6 +573,9 @@ const defaultLocales: LocaleTree = {
externalApiBody: 'Approved scripts may add temporary group pings.',
editUsername: 'Edit Username',
editUsernameBody: 'Your username is unique across CrewLink.',
+ editProfile: 'Edit CrewLink Profile',
+ editProfileBody: 'Change your username and profile photo.',
+ removeProfilePhoto: 'Remove profile photo',
profileSaved: 'CrewLink profile saved.',
deleteGroup: 'Delete Group',
leaveGroup: 'Leave Group',
@@ -592,6 +615,7 @@ const defaultLocales: LocaleTree = {
profile_required: 'Create your CrewLink profile first.',
invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.',
invalid_profile: 'Check your profile details.',
+ invalid_profile_image: 'Choose a valid photo from this phone.',
username_taken: 'That CrewLink username is already taken.',
invalid_group: 'Choose a valid group name and signal colour.',
group_limit: 'You have reached your group limit.',
@@ -1003,14 +1027,16 @@ const defaultLocales: LocaleTree = {
done: 'Done',
authEyebrow: 'Feather network',
authWelcome: 'Welcome to Feather',
- authBody: 'One iFruit account. Every conversation, wherever you sign in.',
+ authBody:
+ 'Your iFruit email is linked automatically. Use your Feather username to continue.',
login: 'Log in',
register: 'Register',
loginTitle: 'Good to see you again',
- loginBody: 'Log in with your iFruit address to continue to Feather.',
- registerTitle: 'Create your iFruit account',
+ loginBody:
+ 'Use your Feather username. Your iFruit email is linked automatically.',
+ registerTitle: 'Create your Feather profile',
registerBody:
- 'Choose an address and secure it with a password. Your Feather profile comes next.',
+ 'Choose a username and optional profile photo. No password is needed.',
email: 'iFruit address',
emailPlaceholder: 'yourname',
password: 'Password',
@@ -1020,23 +1046,25 @@ const defaultLocales: LocaleTree = {
showPassword: 'Show password',
hidePassword: 'Hide password',
loginAction: 'Continue to Feather',
- registerAction: 'Create account',
+ registerAction: 'Create profile',
noAccount: 'New to Feather?',
haveAccount: 'Already registered?',
- registerNow: 'Create an account',
+ registerNow: 'Create a profile',
loginNow: 'Log in',
authTrust:
- 'Your credentials are verified by the server and this phone is linked to your iFruit account.',
+ 'This Feather session is separate from your other apps and linked to your iFruit email.',
profileStep: 'Step 2 of 2',
accountConnected: 'iFruit account connected',
authErrors: {
- invalid_email: 'Enter a valid 3–32 character iFruit address.',
- invalid_password: 'Password must be 6–64 characters.',
- password_mismatch: 'The passwords do not match.',
- invalid_credentials: 'The iFruit address or password is incorrect.',
- email_taken: 'That iFruit address is already registered.',
+ no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
+ invalid_handle: 'Use 3–30 letters, numbers or underscores.',
+ invalid_username: 'That username does not match your Feather profile.',
+ profile_not_found: 'No Feather profile exists for this iFruit email.',
+ already_registered: 'This iFruit email already has a Feather profile.',
+ handle_taken: 'That Feather username is already taken.',
+ invalid_media: 'Choose a valid photo from this phone.',
rate_limited: 'Too many attempts. Try again in a minute.',
- default: 'The account request failed. Please try again.',
+ default: 'Feather could not complete the request. Please try again.',
},
welcome: 'Find your voice',
welcomeBody:
@@ -2537,13 +2565,38 @@ const defaultLocales: LocaleTree = {
photos: 'photos',
activeListings: 'active listings',
signInTitle: 'Sign in to iFruit',
- signInBody:
- 'Use Settings to sign in before selling, saving or messaging.',
+ signInBody: 'Log in to your CityMarkt profile to sell, save or message.',
+ authEyebrow: 'CityMarkt account',
+ authTitle: 'Welcome to CityMarkt',
+ authBody:
+ 'Your iFruit email is linked automatically. Use your CityMarkt username to continue.',
+ login: 'Login',
+ register: 'Register',
+ authUsername: 'Username',
+ authErrors: {
+ no_ifruit_account: 'Connect an iFruit account in Settings first.',
+ invalid_username: 'Enter the username of your CityMarkt profile.',
+ profile_not_found: 'No CityMarkt profile exists for this iFruit email.',
+ profile_exists:
+ 'A CityMarkt profile already exists. Use Login instead.',
+ },
noMessages: 'No conversations',
noMessagesBody: 'Messages about offers will appear here.',
myListings: 'My listings',
favorites: 'Favorites',
+ addFavorite: 'Add to favorites',
+ removeFavorite: 'Remove from favorites',
noProfileListings: 'Nothing here yet',
+ createProfile: 'Create your CityMarkt profile',
+ editProfile: 'Edit profile',
+ profileIntro: 'Your iFruit email stays linked to this profile.',
+ profileEmail: 'iFruit email',
+ displayName: 'Display name',
+ profileBio: 'About you',
+ saveProfile: 'Save profile',
+ cancel: 'Cancel',
+ profileSaved: 'Your profile was saved.',
+ removeProfilePhoto: 'Remove photo',
phone: 'Phone',
contactSeller: 'Contact seller',
messagePlaceholder: 'Hi, is this still available?',
@@ -2683,13 +2736,16 @@ const defaultLocales: LocaleTree = {
signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
authEyebrow: 'Local Pages account',
authWelcome: 'Welcome to Local Pages',
- authBody: 'Sign in or create an iFruit account to build your local profile.',
+ authBody:
+ 'Your iFruit email is linked automatically. Use your Local Pages username to continue.',
login: 'Sign in',
register: 'Register',
loginTitle: 'Continue with iFruit',
- loginBody: 'Your posts, saved items and profile stay linked to your account.',
+ loginBody:
+ 'Your posts, saved items and profile stay linked to your account.',
registerTitle: 'Create an iFruit account',
- registerBody: 'Choose your new iFruit address. Your Local Pages profile comes next.',
+ registerBody:
+ 'Choose your new iFruit address. Your Local Pages profile comes next.',
authEmail: 'iFruit address',
authEmailPlaceholder: 'your.name',
authPassword: 'Password',
@@ -2705,7 +2761,8 @@ const defaultLocales: LocaleTree = {
profileEmail: 'iFruit email',
profileHandle: 'Username',
profileHandlePlaceholder: 'your.name',
- profileHandleHint: 'Use 3–24 lowercase letters, numbers, dots or underscores.',
+ profileHandleHint:
+ 'Use 3–24 lowercase letters, numbers, dots or underscores.',
profileBio: 'Bio',
profileBioPlaceholder: 'Tell the city a little about yourself...',
profilePhoto: 'Profile photo',
@@ -2751,13 +2808,17 @@ const defaultLocales: LocaleTree = {
'Review the listing and publish it when you are ready.',
cityMarktPhotosHint: 'The CityMarkt listing photos will be included.',
authErrors: {
- invalid_email: 'Enter a valid 3–32 character iFruit address.',
- invalid_password: 'Use a password between 6 and 64 characters.',
- invalid_credentials: 'The iFruit address or password is incorrect.',
- email_taken: 'This iFruit address is already registered.',
- password_mismatch: 'The passwords do not match.',
+ no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
+ invalid_username:
+ 'Use 3–24 lowercase letters, numbers, dots or underscores.',
+ profile_not_found:
+ 'No Local Pages profile exists for this iFruit email.',
+ profile_exists: 'This iFruit email already has a Local Pages profile.',
+ invalid_profile: 'Check your Local Pages username.',
+ invalid_profile_image: 'Choose a valid photo from this phone.',
+ profile_handle_taken: 'This Local Pages username is already taken.',
rate_limited: 'Too many attempts. Try again shortly.',
- default: 'The iFruit account request failed.',
+ default: 'Local Pages could not complete the request.',
},
errors: {
profile_required: 'Create your Local Pages profile first.',
@@ -3469,6 +3530,32 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
+ signOut: 'Sign Out',
+ signingOut: 'Signing Out...',
+ signOutTitle: 'Sign out of {app}?',
+ signOutBody:
+ 'You will only be signed out of {app}. Your other iFruit apps stay signed in.',
+ signOutFailed: 'Could not sign out. Please try again.',
+ appAuth: {
+ eyebrow: 'iFruit account',
+ title: 'Continue to {app}',
+ body: 'Use your iFruit email and password. This login applies only to {app}.',
+ login: 'Login',
+ register: 'Register',
+ email: 'iFruit email',
+ password: 'Password',
+ confirm: 'Confirm password',
+ loginAction: 'Log in',
+ registerAction: 'Create account',
+ errors: {
+ invalid_email: 'Enter a valid iFruit email.',
+ invalid_password: 'Password must be 6–64 characters.',
+ invalid_credentials: 'Email or password is incorrect.',
+ email_taken: 'That iFruit email is already registered.',
+ rate_limited: 'Too many attempts. Try again in a minute.',
+ default: 'The account request failed.',
+ },
+ },
use: 'Use',
},
Notifications: {
diff --git a/frontend/src/types/crewlink.ts b/frontend/src/types/crewlink.ts
index ae54bda..f27f0f5 100644
--- a/frontend/src/types/crewlink.ts
+++ b/frontend/src/types/crewlink.ts
@@ -15,15 +15,12 @@ export type CrewLinkColour =
| 'green'
| 'rose'
-export type CrewLinkPingType =
- | 'meeting'
- | 'danger'
- | 'help'
- | 'target'
- | 'info'
+export type CrewLinkPingType = 'meeting' | 'danger' | 'help' | 'target' | 'info'
export type CrewLinkProfile = {
activeGroupId: string | null
+ avatarMediaId: number | null
+ avatarUrl: string | null
id: string
mapVisible: boolean
overheadVisible: boolean
@@ -31,6 +28,7 @@ export type CrewLinkProfile = {
}
export type CrewLinkMember = {
+ avatarUrl?: string | null
coords?: MapPoint & { z: number }
id: string
joinedAt: number
diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue
index 6a4c321..f65bbfa 100644
--- a/frontend/src/views/apps/CityMarktApp.vue
+++ b/frontend/src/views/apps/CityMarktApp.vue
@@ -18,6 +18,7 @@ import {
Inbox,
Laptop,
LayoutGrid,
+ LogOut,
MapPin,
MessageCircle,
MoreHorizontal,
@@ -50,7 +51,10 @@ import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
+import CityMarktAuth from '@/components/citymarkt/CityMarktAuth.vue'
+import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
import { useAccountStore } from '@/stores/account'
+import { useAppAuthStore } from '@/stores/app-auth'
import { useAppStoreStore } from '@/stores/app-store'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMarketplaceStore } from '@/stores/marketplace'
@@ -105,7 +109,10 @@ type MediaContext = {
photos: SelectedPhoto[]
sellStep: number
}
-type ProfileMediaContext = { draft: MarketplaceProfileDraft }
+type ProfileMediaContext = {
+ authMode?: 'login' | 'register'
+ draft: MarketplaceProfileDraft
+}
const phone = usePhoneStore()
const detailActionColors = computed(() => ({
@@ -115,11 +122,15 @@ const detailActionColors = computed(() => ({
const route = useRoute()
const router = useRouter()
const account = useAccountStore()
+const appAuth = useAppAuthStore()
const appStore = useAppStoreStore()
const easyShare = useEasyShareStore()
const marketplace = useMarketplaceStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
+const logoutDialogOpen = ref(false)
+const authMode = ref<'login' | 'register'>('login')
+const authError = ref('')
const tab = ref('discover')
const screen = ref('main')
const selectedListing = ref(null)
@@ -239,7 +250,7 @@ const tabs = [
{ icon: UserRound, id: 'profile' },
] as const
-const isAuthenticated = computed(() => account.email !== '')
+const isAuthenticated = computed(() => appAuth.isSignedIn('citymarkt'))
const localPagesInstalled = computed(
() =>
appStore.isInstalled('local-pages') &&
@@ -529,6 +540,80 @@ async function selectTab(next: Tab): Promise {
}
}
+async function finishAuthentication(): Promise {
+ await marketplace.loadProfile()
+ syncProfileDraft()
+ profileEditing.value = !marketplace.profile?.exists
+ await Promise.all([
+ marketplace.loadOwn(),
+ marketplace.load({ favorites: true }),
+ ])
+ tab.value = 'profile'
+ screen.value = 'main'
+}
+
+function switchAuthMode(mode: 'login' | 'register'): void {
+ authMode.value = mode
+ authError.value = ''
+ profileDraft.value.displayName =
+ mode === 'login'
+ ? (marketplace.profile?.display_name ?? '')
+ : account.email.split('@')[0] ?? ''
+ if (mode === 'login') selectedProfilePhoto.value = null
+}
+
+async function submitCityMarktAuth(): Promise {
+ const username = profileDraft.value.displayName.trim()
+ authError.value = ''
+ if (!account.email) {
+ authError.value = phone.t('Apps.citymarkt.authErrors.no_ifruit_account')
+ return
+ }
+ if (username.length < 2 || username.length > 40) {
+ authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
+ return
+ }
+
+ profilePending.value = true
+ await marketplace.loadProfile()
+ if (authMode.value === 'login') {
+ profilePending.value = false
+ if (!marketplace.profile?.exists) {
+ authError.value = phone.t('Apps.citymarkt.authErrors.profile_not_found')
+ return
+ }
+ if (
+ marketplace.profile.display_name.trim().toLocaleLowerCase(phone.lang) !==
+ username.toLocaleLowerCase(phone.lang)
+ ) {
+ authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
+ return
+ }
+ } else {
+ if (marketplace.profile?.exists) {
+ profilePending.value = false
+ authError.value = phone.t('Apps.citymarkt.authErrors.profile_exists')
+ return
+ }
+ const response = await marketplace.saveProfile({
+ avatarMediaId:
+ selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
+ bio: '',
+ displayName: username,
+ })
+ profilePending.value = false
+ if (!response.success) {
+ authError.value = phone.t(
+ `Apps.citymarkt.errors.${response.error ?? 'default'}`,
+ )
+ return
+ }
+ }
+
+ appAuth.signIn('citymarkt', account.email)
+ await finishAuthentication()
+}
+
async function openListing(
item: Pick,
): Promise {
@@ -578,9 +663,12 @@ function openProfileMedia(app: 'camera' | 'photos'): void {
'photo',
'/apps/citymarkt?profileEdit=1',
1,
- { draft: { ...profileDraft.value } } satisfies ProfileMediaContext,
+ {
+ authMode: isAuthenticated.value ? undefined : authMode.value,
+ draft: { ...profileDraft.value },
+ } satisfies ProfileMediaContext,
)
- void router.push(app === 'photos' ? '/apps/photos?picker=1' : '/apps/camera?picker=1')
+ void router.push(`/apps/${app}?mediaAttachment=photo`)
}
function removeProfilePhoto(): void {
@@ -905,11 +993,13 @@ onMounted(async () => {
}
if (profileSelection?.context) {
profileDraft.value = profileSelection.context.draft
+ if (profileSelection.context.authMode)
+ authMode.value = profileSelection.context.authMode
if (profileSelection.media[0]) {
selectedProfilePhoto.value = profileSelection.media[0]
profileDraft.value.avatarMediaId = profileSelection.media[0].id
}
- profileEditing.value = true
+ profileEditing.value = isAuthenticated.value
tab.value = 'profile'
screen.value = 'main'
}
@@ -932,6 +1022,8 @@ onMounted(async () => {
}
await marketplace.loadCounts()
} else {
+ await marketplace.loadProfile()
+ switchAuthMode(marketplace.profile?.exists ? 'login' : 'register')
tab.value = 'profile'
screen.value = 'main'
}
@@ -1127,6 +1219,9 @@ onMounted(async () => {
{{ phone.t('Apps.citymarkt.signInTitle') }}
{{ phone.t('Apps.citymarkt.signInBody') }}
+
+ {{ phone.t('Apps.citymarkt.login') }}
+
{{
@@ -1156,9 +1251,17 @@ onMounted(async () => {
-
-
{{ phone.t('Apps.citymarkt.signInTitle') }}
-
{{ phone.t('Apps.citymarkt.signInBody') }}
+
@@ -1255,6 +1358,10 @@ onMounted(async () => {
>
+
+
+ {{ phone.t('Common.signOut') }}
+
@@ -1849,6 +1956,11 @@ onMounted(async () => {
+
{{ feedback }}
@@ -2503,6 +2615,11 @@ onMounted(async () => {
.citymarkt__profile-actions button:disabled {
opacity: .4;
}
+.citymarkt__logout {
+ width: 100%;
+ margin-top: 12px;
+ color: #ff796f;
+}
:global(.citymarkt--light) .citymarkt__profile-editor {
border-color: #00000012;
}
diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue
index 9680cd4..d54e5bd 100644
--- a/frontend/src/views/apps/CrewLinkApp.vue
+++ b/frontend/src/views/apps/CrewLinkApp.vue
@@ -23,6 +23,7 @@ import {
} from 'konsta/vue'
import {
AlertTriangle,
+ Camera,
Check,
CircleDot,
Copy,
@@ -32,7 +33,9 @@ import {
EyeOff,
Flag,
Info,
+ Images,
LocateFixed,
+ LogOut,
Map as MapIcon,
MapPin,
Navigation,
@@ -52,13 +55,7 @@ import {
X,
Zap,
} from 'lucide-vue-next'
-import {
- computed,
- nextTick,
- onBeforeUnmount,
- onMounted,
- ref,
-} from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
@@ -69,9 +66,15 @@ import {
defaultMapWorldToPercent,
type MapPoint,
} from '@/features/map/defaultMapGeometry'
+import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
+import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
+import { useAccountStore } from '@/stores/account'
+import { useAppAuthStore } from '@/stores/app-auth'
import { useCrewLinkStore } from '@/stores/crewlink'
import { useEasyShareStore } from '@/stores/easyshare'
+import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
+import type { PhoneMedia } from '@/types/media'
import type {
CrewLinkColour,
CrewLinkMember,
@@ -92,11 +95,24 @@ type CrewLinkSheet =
| 'nearby'
| 'ping'
| 'edit-group'
+ | 'edit-profile'
| 'member'
| null
+type AuthMediaContext = {
+ mode: 'login' | 'register'
+ selectedPhoto: PhoneMedia | null
+ username: string
+}
+type ProfileMediaContext = {
+ selectedPhoto: PhoneMedia | null
+ username: string
+}
const phone = usePhoneStore()
+const account = useAccountStore()
+const appAuth = useAppAuthStore()
const crew = useCrewLinkStore()
+const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const mainlandMapUrl = `${import.meta.env.BASE_URL}img/maps/gtav-map.svg`
@@ -104,6 +120,13 @@ const cayoMapUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
const activeTab = ref
('map')
const sheet = ref(null)
const username = ref('')
+const authMode = ref<'login' | 'register'>('login')
+const authUsername = ref('')
+const authProfilePhoto = ref(null)
+const authPending = ref(false)
+const authError = ref('')
+const selectedProfilePhoto = ref(null)
+const profileAvatarRemoved = ref(false)
const groupName = ref('')
const groupColour = ref('cyan')
const inviteCode = ref('')
@@ -121,10 +144,15 @@ const nearbyPlayers = ref([])
const selectedMember = ref(null)
const selectedPing = ref(null)
const formError = ref('')
+const logoutDialogOpen = ref(false)
const toastText = ref('')
-const pendingGroupSetting = ref<'allowMemberPings' | 'overheadAllowed' | null>(null)
+const pendingGroupSetting = ref<'allowMemberPings' | 'overheadAllowed' | null>(
+ null,
+)
const pendingVisibility = ref<'mapVisible' | 'overheadVisible' | null>(null)
-const confirmAction = ref<'delete-group' | 'leave-group' | 'remove-member' | 'transfer-owner' | null>(null)
+const confirmAction = ref<
+ 'delete-group' | 'leave-group' | 'remove-member' | 'transfer-owner' | null
+>(null)
const zoom = ref(1.45)
const pan = ref({ x: 0, y: 0 })
const viewportRef = ref(null)
@@ -185,18 +213,21 @@ const ownMember = computed(() =>
const onlineMembers = computed(
() => activeGroup.value?.members.filter((member) => member.online) ?? [],
)
-const visibleMapMembers = computed(
- () => onlineMembers.value.filter((member) => Boolean(member.coords)),
+const visibleMapMembers = computed(() =>
+ onlineMembers.value.filter((member) => Boolean(member.coords)),
)
const canCoordinate = computed(
- () => roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.coordinator,
+ () =>
+ roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.coordinator,
)
const canModerate = computed(
() => roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.moderator,
)
const canPing = computed(
- () =>
- canModerate.value || Boolean(activeGroup.value?.allowMemberPings),
+ () => canModerate.value || Boolean(activeGroup.value?.allowMemberPings),
+)
+const authUsernameValid = computed(() =>
+ /^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(authUsername.value.trim()),
)
const canvasStyle = computed(() => ({
aspectRatio: String(
@@ -223,11 +254,17 @@ const mapCenterCoords = computed(() => {
return defaultMapPercentToWorld({
x: Math.min(
1,
- Math.max(0, (viewport.left + viewport.width / 2 - canvas.left) / canvas.width),
+ Math.max(
+ 0,
+ (viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
+ ),
),
y: Math.min(
1,
- Math.max(0, (viewport.top + viewport.height / 2 - canvas.top) / canvas.height),
+ Math.max(
+ 0,
+ (viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
+ ),
),
})
})
@@ -252,6 +289,107 @@ function showToast(message: string): void {
}, 2400)
}
+function switchAuthMode(mode: 'login' | 'register'): void {
+ authMode.value = mode
+ authProfilePhoto.value = null
+ authUsername.value =
+ mode === 'register'
+ ? (account.email.split('@')[0] ?? '')
+ .replace(/[^a-z0-9._]/gi, '_')
+ .slice(0, 20)
+ : ''
+ authError.value = ''
+}
+
+function authErrorText(code?: string): string {
+ const known = [
+ 'invalid_profile_image',
+ 'invalid_username',
+ 'no_ifruit_account',
+ 'profile_exists',
+ 'profile_not_found',
+ 'rate_limited',
+ 'username_taken',
+ ]
+ return t(
+ `authErrors.${code && known.includes(code) ? code : 'request_failed'}`,
+ )
+}
+
+async function submitAuthentication(): Promise {
+ authError.value = ''
+ if (!account.email) {
+ authError.value = authErrorText('no_ifruit_account')
+ return
+ }
+ if (!authUsernameValid.value) {
+ authError.value = authErrorText('invalid_username')
+ return
+ }
+
+ const submittedUsername = authUsername.value.trim()
+ authPending.value = true
+ const loaded = await crew.bootstrap()
+ if (!loaded) {
+ authPending.value = false
+ authError.value = authErrorText(crew.error)
+ return
+ }
+
+ if (authMode.value === 'login') {
+ authPending.value = false
+ if (!crew.profile) {
+ authError.value = authErrorText('profile_not_found')
+ return
+ }
+ if (
+ crew.profile.username.toLowerCase() !== submittedUsername.toLowerCase()
+ ) {
+ authError.value = authErrorText('invalid_username')
+ return
+ }
+ } else {
+ if (crew.profile) {
+ authPending.value = false
+ authError.value = authErrorText('profile_exists')
+ return
+ }
+ const response = await crew.createProfile(
+ submittedUsername,
+ authProfilePhoto.value?.id ?? 0,
+ )
+ authPending.value = false
+ if (!response.success) {
+ authError.value = authErrorText(response.error)
+ return
+ }
+ }
+
+ appAuth.signIn('crewlink', account.email)
+ username.value = crew.profile?.username ?? submittedUsername
+ authUsername.value = ''
+ authProfilePhoto.value = null
+ openSharedInvite()
+}
+
+function openAuthMedia(app: 'camera' | 'photos'): void {
+ messageMedia.begin(
+ 'crewlink:auth-avatar',
+ 'photo',
+ '/apps/crewlink?auth=register',
+ 1,
+ {
+ mode: authMode.value,
+ selectedPhoto: authProfilePhoto.value,
+ username: authUsername.value,
+ } satisfies AuthMediaContext,
+ )
+ void router.push({
+ path: `/apps/${app}`,
+ query: { mediaAttachment: 'photo' },
+ })
+}
+
function shareProfile(): void {
const profile = crew.profile
if (!profile) return
@@ -303,7 +441,10 @@ function updateValue(
}
function colourValue(colour: CrewLinkColour): string {
- return colours.find((candidate) => candidate.id === colour)?.value ?? colours[0].value
+ return (
+ colours.find((candidate) => candidate.id === colour)?.value ??
+ colours[0].value
+ )
}
function roleLabel(role: CrewLinkRole): string {
@@ -314,7 +455,10 @@ function memberInitials(member: CrewLinkMember): string {
return member.username.slice(0, 2).toUpperCase()
}
-function markerStyle(coords: MapPoint, offset = '-50%'): Record {
+function markerStyle(
+ coords: MapPoint,
+ offset = '-50%',
+): Record {
const point = defaultMapWorldToPercent(coords)
return {
left: `${point.x * 100}%`,
@@ -331,7 +475,8 @@ function memberStatus(member: CrewLinkMember): string {
function expiresIn(timestamp: number): string {
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000))
- if (seconds >= 60) return t('expiresMinutes', { count: String(Math.ceil(seconds / 60)) })
+ if (seconds >= 60)
+ return t('expiresMinutes', { count: String(Math.ceil(seconds / 60)) })
return t('expiresSeconds', { count: String(seconds) })
}
@@ -357,9 +502,42 @@ function closeSheet(): void {
if (crew.isLoading) return
sheet.value = null
selectedMember.value = null
+ selectedProfilePhoto.value = null
+ profileAvatarRemoved.value = false
formError.value = ''
}
+function editOwnProfile(): void {
+ if (!crew.profile) return
+ username.value = crew.profile.username
+ selectedProfilePhoto.value = null
+ profileAvatarRemoved.value = false
+ formError.value = ''
+ sheet.value = 'edit-profile'
+}
+
+function openProfileMedia(app: 'camera' | 'photos'): void {
+ messageMedia.begin(
+ 'crewlink:profile-avatar',
+ 'photo',
+ '/apps/crewlink?profileEdit=1',
+ 1,
+ {
+ selectedPhoto: selectedProfilePhoto.value,
+ username: username.value,
+ } satisfies ProfileMediaContext,
+ )
+ void router.push({
+ path: `/apps/${app}`,
+ query: { mediaAttachment: 'photo' },
+ })
+}
+
+function removeProfilePhoto(): void {
+ selectedProfilePhoto.value = null
+ profileAvatarRemoved.value = true
+}
+
async function createProfile(): Promise {
const response = await crew.createProfile(username.value.trim())
if (!response.success) {
@@ -371,7 +549,10 @@ async function createProfile(): Promise {
}
async function createGroup(): Promise {
- const response = await crew.createGroup(groupName.value.trim(), groupColour.value)
+ const response = await crew.createGroup(
+ groupName.value.trim(),
+ groupColour.value,
+ )
if (!response.success) {
formError.value = errorText(response.error)
return
@@ -405,13 +586,20 @@ async function switchGroup(groupId: string): Promise {
async function saveProfile(): Promise {
if (!crew.profile) return
+ const avatarMediaId =
+ selectedProfilePhoto.value?.id ??
+ (profileAvatarRemoved.value ? 0 : crew.profile.avatarMediaId)
const response = await crew.updateProfile(
username.value.trim(),
crew.profile.mapVisible,
crew.profile.overheadVisible,
+ avatarMediaId,
)
if (!response.success) formError.value = errorText(response.error)
- else showToast(t('profileSaved'))
+ else {
+ closeSheet()
+ showToast(t('profileSaved'))
+ }
}
async function updateVisibility(
@@ -479,9 +667,7 @@ function togglePingAtMapCenter(): void {
function copyInviteCode(): void {
if (!activeGroup.value?.inviteCode) return
showToast(
- copyText(activeGroup.value.inviteCode)
- ? t('codeCopied')
- : errorText(),
+ copyText(activeGroup.value.inviteCode) ? t('codeCopied') : errorText(),
)
}
@@ -553,12 +739,20 @@ async function performConfirmedAction(): Promise {
confirmAction.value = null
if (!activeGroup.value) return
let response
- if (action === 'delete-group') response = await crew.deleteGroup(activeGroup.value.id)
- else if (action === 'leave-group') response = await crew.leave(activeGroup.value.id)
+ if (action === 'delete-group')
+ response = await crew.deleteGroup(activeGroup.value.id)
+ else if (action === 'leave-group')
+ response = await crew.leave(activeGroup.value.id)
else if (action === 'remove-member' && selectedMember.value) {
- response = await crew.removeMember(activeGroup.value.id, selectedMember.value.id)
+ response = await crew.removeMember(
+ activeGroup.value.id,
+ selectedMember.value.id,
+ )
} else if (action === 'transfer-owner' && selectedMember.value) {
- response = await crew.transferOwner(activeGroup.value.id, selectedMember.value.id)
+ response = await crew.transferOwner(
+ activeGroup.value.id,
+ selectedMember.value.id,
+ )
}
if (!response?.success) showToast(errorText(response?.error))
else {
@@ -599,11 +793,27 @@ async function removePing(ping: CrewLinkPing): Promise {
}
}
-async function routeTo(coords: { x: number; y: number; z: number }): Promise {
+async function routeTo(coords: {
+ x: number
+ y: number
+ z: number
+}): Promise {
const response = await nuiCall('map:setWaypoint', { coords })
showToast(t(response.success ? 'routeSet' : 'errors.request_failed'))
}
+function routeToSelectedMember(): void {
+ if (!selectedMember.value?.coords) return
+ void routeTo(selectedMember.value.coords)
+ selectedMember.value = null
+}
+
+function routeToSelectedPing(): void {
+ if (!selectedPing.value) return
+ void routeTo(selectedPing.value.coords)
+ selectedPing.value = null
+}
+
function centerOn(coords: MapPoint): void {
const viewport = viewportRef.value?.getBoundingClientRect()
const canvas = canvasRef.value
@@ -658,7 +868,10 @@ function centerOwnLocation(): void {
}
function changeZoom(direction: -1 | 1): void {
- zoom.value = Math.max(0.85, Math.min(8, zoom.value * (direction > 0 ? 1.32 : 0.76)))
+ zoom.value = Math.max(
+ 0.85,
+ Math.min(8, zoom.value * (direction > 0 ? 1.32 : 0.76)),
+ )
}
function onPointerDown(event: PointerEvent): void {
@@ -700,8 +913,30 @@ function onCrewLinkMessage(event: MessageEvent): void {
}
onMounted(async () => {
- await crew.bootstrap()
+ const authSelection = messageMedia.consumeMany(
+ 'crewlink:auth-avatar',
+ )
+ const profileSelection = messageMedia.consumeMany(
+ 'crewlink:profile-avatar',
+ )
+ if (authSelection) {
+ authMode.value = authSelection.context?.mode ?? 'register'
+ authUsername.value = authSelection.context?.username ?? ''
+ authProfilePhoto.value =
+ authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null
+ }
+ if (appAuth.isSignedIn('crewlink')) await crew.bootstrap()
username.value = crew.profile?.username ?? ''
+ if (profileSelection && crew.profile) {
+ username.value = profileSelection.context?.username ?? crew.profile.username
+ selectedProfilePhoto.value =
+ profileSelection.media[0] ??
+ profileSelection.context?.selectedPhoto ??
+ null
+ profileAvatarRemoved.value = false
+ activeTab.value = 'profile'
+ sheet.value = 'edit-profile'
+ }
openSharedInvite()
await nextTick()
fitOnlineMembers()
@@ -719,7 +954,36 @@ onBeforeUnmount(() => {
-
+
+
+
+
+
@@ -733,8 +997,8 @@ onBeforeUnmount(() => {
{{ t('privateNetwork') }}
{{ t('signInTitle') }}
{{ t('signInBody') }}
-
- {{ t('openSettings') }}
+
+ {{ t('backToLogin') }}
@@ -759,19 +1023,40 @@ onBeforeUnmount(() => {
@keydown.enter="createProfile"
/>
- {{ formError }}
-
+
+ {{ formError }}
+
+
{{ t('createProfile') }}
- {{ t('privacyNote') }}
+
+ {{ phone.t('Common.signOut') }}
+
+ {{ t('privacyNote') }}
-
+
@@ -781,6 +1066,15 @@ onBeforeUnmount(() => {
{{ t('joinGroup') }}
+
+ {{ phone.t('Common.signOut') }}
+
@@ -796,8 +1090,11 @@ onBeforeUnmount(() => {
v-for="member in onlineMembers.slice(0, 4)"
:key="member.id"
:style="{ borderColor: activeColour }"
- >{{ memberInitials(member) }}
- +{{ onlineMembers.length - 4 }}
+ >{{ memberInitials(member) }}
+ +{{ onlineMembers.length - 4 }}
@@ -810,16 +1107,20 @@ onBeforeUnmount(() => {
@pointercancel="onPointerUp"
@wheel="onWheel"
>
-
+
![]()
{
type="button"
class="crewlink-member-marker"
:class="{ 'is-self': member.id === crew.profile?.id }"
- :style="{ ...markerStyle(member.coords!), ...activeCrewStyle }"
+ :style="{
+ ...markerStyle(member.coords!),
+ ...activeCrewStyle,
+ }"
@pointerdown.stop
@click.stop="selectedMember = member"
>
@@ -843,7 +1147,10 @@ onBeforeUnmount(() => {
:key="ping.id"
type="button"
class="crewlink-ping-marker"
- :style="{ ...markerStyle(ping.coords, '-100%'), '--ping': pingColours[ping.type] }"
+ :style="{
+ ...markerStyle(ping.coords, '-100%'),
+ '--ping': pingColours[ping.type],
+ }"
@pointerdown.stop
@click.stop="selectedPing = ping"
>
@@ -851,12 +1158,38 @@ onBeforeUnmount(() => {
{{ ping.label }}
-
+
+
+
- +
- −
-
-
+
+ +
+
+
+ −
+
+
+
+
+
+
+
{{ t('status.live') }}
@@ -866,10 +1199,24 @@ onBeforeUnmount(() => {
- {{ activeGroup.memberCount }} {{ t(activeGroup.memberCount === 1 ? 'member' : 'members') }}{{ t('openCrew') }}
+ {{ activeGroup.memberCount }}
+ {{
+ t(activeGroup.memberCount === 1 ? 'member' : 'members')
+ }}{{ t('openCrew') }}
-
- {{ t('newPing') }}{{ t('shareLocation') }}
+
+ {{ t('newPing') }}{{ t('shareLocation') }}
@@ -879,18 +1226,54 @@ onBeforeUnmount(() => {
{{ t('activeCrew') }}
{{ activeGroup.name }}
-
{{ t(activeGroup.memberCount === 1 ? 'groupSummarySingle' : 'groupSummary', { online: String(onlineMembers.length), total: String(activeGroup.memberCount) }) }}
+
+ {{
+ t(
+ activeGroup.memberCount === 1
+ ? 'groupSummarySingle'
+ : 'groupSummary',
+ {
+ online: String(onlineMembers.length),
+ total: String(activeGroup.memberCount),
+ },
+ )
+ }}
+
- {{ roleLabel(activeGroup.role) }}
- {{ t('private') }}
+ {{
+ roleLabel(activeGroup.role)
+ }}
+ {{
+ t('private')
+ }}
- {{ t('nearby') }}
- {{ t('copyCode') }}
- {{ t('shareInvite') }}
- {{ t('manage') }}
+
+ {{ t('nearby') }}
+
+
+ {{ t('copyCode') }}
+
+
+ {{ t('shareInvite') }}
+
+
+ {{ t('manage') }}
+
@@ -898,16 +1281,36 @@ onBeforeUnmount(() => {
-
-
{{ invite.groupName }}{{ t('invitedBy', { username: invite.inviterUsername }) }}
-
-
+
+
+ {{ invite.groupName }}{{
+ t('invitedBy', { username: invite.inviterUsername })
+ }}
+
+
+
+
+
+
+
-
{{ t(activeGroup.memberCount === 1 ? 'member' : 'members') }}
+
{{
+ t(activeGroup.memberCount === 1 ? 'member' : 'members')
+ }}
{
:link="member.id !== crew.profile?.id && canModerate"
@click="selectMember(member)"
>
- {{ member.username }}
- {{ roleLabel(member.role) }} · {{ memberStatus(member) }}
+ {{
+ member.username
+ }}
+ {{ roleLabel(member.role) }} ·
+ {{ memberStatus(member) }}
- {{ memberInitials(member) }}
+
+
+ {{ memberInitials(member) }}
+
+
-
+
-
@@ -2084,6 +2036,12 @@ onMounted(async () => {
+
+
@@ -2219,6 +2177,7 @@ onMounted(async () => {
.feather-app {
--feather-blue: #438cf5;
--feather-blue-dark: #2867d8;
+ --color-primary: var(--feather-blue);
background: #fff;
color: #111923;
}
@@ -2346,12 +2305,16 @@ onMounted(async () => {
box-shadow: 0 15px 35px rgb(45 111 224 / 25%);
}
.feather-auth {
+ --auth-accent: var(--feather-blue);
+ --panel: #18212b;
min-height: 100%;
overflow-y: auto;
- padding: 25px 15px 30px;
+ padding: 68px 15px 34px;
+ color: #f4f7fa;
background:
- radial-gradient(circle at 85% 2%, rgb(90 183 255 / 19%), transparent 34%),
- radial-gradient(circle at 0 37%, rgb(67 140 245 / 9%), transparent 38%);
+ radial-gradient(circle at 85% 5%, rgb(90 183 255 / 18%), transparent 31%),
+ radial-gradient(circle at 0 42%, rgb(67 140 245 / 8%), transparent 36%),
+ #0f151b;
}
.feather-auth__hero {
display: flex;
@@ -2423,31 +2386,41 @@ onMounted(async () => {
.feather-auth__fields :deep(.k-list-item-media) {
color: var(--feather-blue);
}
-.feather-auth__domain,
-.feather-auth__reveal {
- position: absolute;
- top: 50%;
- right: 16px;
- transform: translateY(-50%);
+.feather-auth__photo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 2px 5px 10px;
+ text-align: left;
}
-.feather-auth__domain {
- pointer-events: none;
- color: #7b8796;
- font-size: 11px;
-}
-.feather-auth__reveal {
+.feather-auth__photo > span {
display: grid;
+ width: 62px;
+ height: 62px;
+ flex: none;
place-items: center;
- width: 30px;
- height: 30px;
- border: 0;
+ overflow: hidden;
border-radius: 50%;
- color: #788493;
- background: transparent;
-}
-.feather-auth__reveal:active {
+ color: var(--feather-blue);
background: rgb(67 140 245 / 10%);
}
+.feather-auth__photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.feather-auth__photo > div {
+ display: grid;
+ min-width: 0;
+ flex: 1;
+ gap: 6px;
+}
+.feather-auth__photo :deep(.k-button) {
+ min-height: 34px;
+ justify-content: flex-start;
+ gap: 6px;
+ font-size: 11px;
+}
.feather-auth__error {
margin: 0 4px 10px;
border-radius: 10px;
@@ -4351,6 +4324,19 @@ onMounted(async () => {
font-size: 10.5px;
font-weight: 750;
}
+.feather-app--active
+ .feather-profile__actions
+ :deep(.feather-profile-action--logout) {
+ grid-column: 1 / -1;
+ border-color: color-mix(in srgb, #f04f65 62%, transparent);
+ color: #f04f65;
+}
+.feather-onboarding__logout {
+ width: 100%;
+ margin-top: 8px;
+ border-color: color-mix(in srgb, #f04f65 62%, transparent);
+ color: #f04f65;
+}
.feather-app--active .feather-profile-action span {
min-width: 0;
overflow: hidden;
diff --git a/frontend/src/views/apps/LocalPagesApp.vue b/frontend/src/views/apps/LocalPagesApp.vue
index 3557f48..da66f1a 100644
--- a/frontend/src/views/apps/LocalPagesApp.vue
+++ b/frontend/src/views/apps/LocalPagesApp.vue
@@ -6,12 +6,10 @@ import {
ChevronLeft,
ChevronRight,
Compass,
- Eye,
- EyeOff,
Heart,
ImagePlus,
Images,
- KeyRound,
+ LogOut,
Mail,
MapPin,
Pencil,
@@ -37,7 +35,10 @@ import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
+import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
+import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
import { useAccountStore } from '@/stores/account'
+import { useAppAuthStore } from '@/stores/app-auth'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMarketplaceStore } from '@/stores/marketplace'
@@ -46,11 +47,6 @@ import { usePhoneStore } from '@/stores/phone'
import type { MarketplaceListing } from '@/types/marketplace'
import type { PagesCategory, PagesPost, PagesProfileDraft } from '@/types/pages'
import type { PhoneMedia } from '@/types/media'
-import {
- filterMailAddressInput,
- MAIL_ADDRESS_INPUT_MAX_LENGTH,
- normalizeMailAddress,
-} from '@/utils/mail'
type SelectedPhoto = { background: string; id: string }
type ComposeDraft = {
@@ -62,12 +58,18 @@ type ComposeDraft = {
}
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
type ProfileMediaContext = { draft: PagesProfileDraft }
+type AuthMediaContext = {
+ mode: 'login' | 'register'
+ selectedPhoto: PhoneMedia | null
+ username: string
+}
type Screen = 'main' | 'detail' | 'compose'
type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore()
const account = useAccountStore()
+const appAuth = useAppAuthStore()
const messageMedia = useMessageMediaStore()
const easyShare = useEasyShareStore()
const marketplace = useMarketplaceStore()
@@ -85,13 +87,18 @@ const feedback = ref('')
const reactionPending = ref(false)
const onboardingReady = ref(false)
const authMode = ref<'login' | 'register'>('login')
-const authForm = ref({ confirm: '', email: '', password: '' })
+const authUsername = ref('')
+const authProfilePhoto = ref(null)
const authPending = ref(false)
-const authPasswordVisible = ref(false)
const authError = ref('')
const profileEditing = ref(false)
const profilePending = ref(false)
-const profileDraft = ref({ avatarMediaId: 0, bio: '', handle: '' })
+const logoutDialogOpen = ref(false)
+const profileDraft = ref({
+ avatarMediaId: 0,
+ bio: '',
+ handle: '',
+})
const selectedProfilePhoto = ref(null)
const pickedPhotos = ref([])
const cityMarktListing = ref(null)
@@ -105,32 +112,60 @@ const draft = ref({
})
const categoryIds: PagesCategory[] = [
- 'recommendation', 'wanted', 'service', 'event', 'place', 'community', 'citymarkt',
+ 'recommendation',
+ 'wanted',
+ 'service',
+ 'event',
+ 'place',
+ 'community',
+ 'citymarkt',
]
const composeCategoryIds = categoryIds.filter((item) => item !== 'citymarkt')
-const districts = ['los_santos', 'vinewood', 'vespucci', 'south_los_santos', 'sandy_shores', 'paleto_bay', 'blaine_county']
+const districts = [
+ 'los_santos',
+ 'vinewood',
+ 'vespucci',
+ 'south_los_santos',
+ 'sandy_shores',
+ 'paleto_bay',
+ 'blaine_county',
+]
const categoryOptions = computed(() => [
{ label: phone.t('Apps.localPages.allCategories'), value: 'all' },
...categoryIds.map((value) => ({ label: label('categories', value), value })),
])
-const composeCategoryOptions = computed(() => composeCategoryIds.map((value) => ({
- label: label('categories', value), value,
-})))
-const districtOptions = computed(() => districts.map((value) => ({
- label: phone.t(`Apps.citymarkt.districts.${value}`), value,
-})))
-const displayedPosts = computed(() => tab.value === 'profile'
- ? (profileMode.value === 'own' ? pages.ownItems : pages.savedItems)
- : pages.items)
-const isAuthenticated = computed(() => Boolean(account.email))
-const selectedPhotos = computed(() => draft.value.images
- .map((id) => pickedPhotos.value.find((photo) => photo.id === id))
- .filter((photo) => photo !== undefined))
-const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
- gradient: photo.background,
- media_id: photo.id,
- sort_order: index + 1,
-})))
+const composeCategoryOptions = computed(() =>
+ composeCategoryIds.map((value) => ({
+ label: label('categories', value),
+ value,
+ })),
+)
+const districtOptions = computed(() =>
+ districts.map((value) => ({
+ label: phone.t(`Apps.citymarkt.districts.${value}`),
+ value,
+ })),
+)
+const displayedPosts = computed(() =>
+ tab.value === 'profile'
+ ? profileMode.value === 'own'
+ ? pages.ownItems
+ : pages.savedItems
+ : pages.items,
+)
+const isAuthenticated = computed(() => appAuth.isSignedIn('local-pages'))
+const selectedPhotos = computed(() =>
+ draft.value.images
+ .map((id) => pickedPhotos.value.find((photo) => photo.id === id))
+ .filter((photo) => photo !== undefined),
+)
+const selectedImages = computed(() =>
+ selectedPhotos.value.map((photo, index) => ({
+ gradient: photo.background,
+ media_id: photo.id,
+ sort_order: index + 1,
+ })),
+)
const canPublish = computed(() => {
const title = draft.value.title.trim().length
const body = draft.value.body.trim().length
@@ -138,20 +173,26 @@ const canPublish = computed(() => {
})
const canSaveProfile = computed(() => {
const handle = profileDraft.value.handle.trim().toLowerCase()
- return handle.length >= 3
- && handle.length <= 24
- && /^[a-z0-9][a-z0-9._]*[a-z0-9]$/.test(handle)
- && profileDraft.value.bio.trim().length <= 160
+ return (
+ handle.length >= 3 &&
+ handle.length <= 24 &&
+ /^[a-z0-9][a-z0-9._]*[a-z0-9]$/.test(handle) &&
+ profileDraft.value.bio.trim().length <= 160
+ )
})
-const profileAvatarUrl = computed(() => selectedProfilePhoto.value?.url
- ?? (profileDraft.value.avatarMediaId > 0 ? pages.profile?.avatar_url : null))
-const authEmailValid = computed(() => normalizeMailAddress(authForm.value.email) !== null)
-const authPasswordValid = computed(() => {
- const length = authForm.value.password.length
- return length >= 6 && length <= 64
+const profileAvatarUrl = computed(
+ () =>
+ selectedProfilePhoto.value?.url ??
+ (profileDraft.value.avatarMediaId > 0 ? pages.profile?.avatar_url : null),
+)
+const authUsernameValid = computed(() => {
+ const username = authUsername.value.trim().toLowerCase()
+ return (
+ username.length >= 3 &&
+ username.length <= 24 &&
+ /^[a-z0-9][a-z0-9._]*[a-z0-9]$/.test(username)
+ )
})
-const authConfirmValid = computed(() => authMode.value === 'login'
- || (authForm.value.confirm.length > 0 && authForm.value.confirm === authForm.value.password))
function label(group: string, value: string): string {
return phone.t(`Apps.localPages.${group}.${value}`)
@@ -162,12 +203,16 @@ function relativeDate(value: number): string {
const hours = Math.max(1, Math.floor(elapsed / 3_600_000))
return hours < 24
? phone.t('Apps.localPages.hoursAgo', { count: String(hours) })
- : phone.t('Apps.localPages.daysAgo', { count: String(Math.floor(hours / 24)) })
+ : phone.t('Apps.localPages.daysAgo', {
+ count: String(Math.floor(hours / 24)),
+ })
}
function showFeedback(key: string): void {
feedback.value = phone.t(key)
- window.setTimeout(() => { feedback.value = '' }, 2600)
+ window.setTimeout(() => {
+ feedback.value = ''
+ }, 2600)
}
async function loadFeed(): Promise {
@@ -210,64 +255,112 @@ async function selectTab(next: Tab): Promise {
}
}
-function updateAuthEmail(event: Event): void {
- const input = event.target as HTMLInputElement
- const filtered = filterMailAddressInput(input.value)
- if (input.value !== filtered) input.value = filtered
- authForm.value.email = filtered
-}
-
-function inputValue(event: Event): string {
- return (event.target as HTMLInputElement).value
-}
-
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
- authForm.value.confirm = ''
+ authProfilePhoto.value = null
+ authUsername.value =
+ mode === 'register'
+ ? (account.email.split('@')[0] ?? '')
+ .replace(/[^a-z0-9._]/gi, '_')
+ .slice(0, 24)
+ : ''
authError.value = ''
}
function authErrorMessage(error?: string): string {
- const known = ['invalid_email', 'invalid_password', 'invalid_credentials', 'email_taken', 'rate_limited']
- return phone.t(`Apps.localPages.authErrors.${error && known.includes(error) ? error : 'default'}`)
+ const known = [
+ 'invalid_profile',
+ 'invalid_profile_image',
+ 'invalid_username',
+ 'no_ifruit_account',
+ 'profile_exists',
+ 'profile_handle_taken',
+ 'profile_not_found',
+ 'rate_limited',
+ ]
+ return phone.t(
+ `Apps.localPages.authErrors.${error && known.includes(error) ? error : 'default'}`,
+ )
}
async function submitAuth(): Promise {
authError.value = ''
- if (!authEmailValid.value) {
- authError.value = authErrorMessage('invalid_email')
+ if (!account.email) {
+ authError.value = authErrorMessage('no_ifruit_account')
return
}
- if (!authPasswordValid.value) {
- authError.value = authErrorMessage('invalid_password')
+ if (!authUsernameValid.value) {
+ authError.value = authErrorMessage('invalid_username')
return
}
- if (!authConfirmValid.value) {
- authError.value = phone.t('Apps.localPages.authErrors.password_mismatch')
- return
- }
- const email = normalizeMailAddress(authForm.value.email)
- if (!email) return
+
+ const username = authUsername.value.trim().toLowerCase()
authPending.value = true
- const response = authMode.value === 'login'
- ? await account.login(email, authForm.value.password)
- : await account.register(email, authForm.value.password)
- authPending.value = false
- if (!response.success) {
- authError.value = authErrorMessage(response.error)
+ const loaded = await pages.loadProfile()
+ if (!loaded || !pages.profile) {
+ authPending.value = false
+ authError.value = authErrorMessage()
return
}
- authForm.value = { confirm: '', email: '', password: '' }
- authPasswordVisible.value = false
+
+ if (authMode.value === 'login') {
+ authPending.value = false
+ if (!pages.profile.exists) {
+ authError.value = authErrorMessage('profile_not_found')
+ return
+ }
+ if (pages.profile.handle.toLowerCase() !== username) {
+ authError.value = authErrorMessage('invalid_username')
+ return
+ }
+ } else {
+ if (pages.profile.exists) {
+ authPending.value = false
+ authError.value = authErrorMessage('profile_exists')
+ return
+ }
+ const response = await pages.saveProfile({
+ avatarMediaId: authProfilePhoto.value?.id ?? 0,
+ bio: '',
+ handle: username,
+ })
+ authPending.value = false
+ if (!response.success) {
+ authError.value = authErrorMessage(response.error)
+ return
+ }
+ }
+
+ appAuth.signIn('local-pages', account.email)
+ authUsername.value = ''
+ authProfilePhoto.value = null
await pages.loadProfile()
ensureBrowserProfile()
syncProfileDraft()
- profileEditing.value = !pages.profile?.exists
+ profileEditing.value = false
tab.value = 'profile'
screen.value = 'main'
onboardingReady.value = true
}
+function openAuthMedia(app: 'camera' | 'photos'): void {
+ messageMedia.begin(
+ 'local-pages:auth-avatar',
+ 'photo',
+ '/apps/local-pages?auth=register',
+ 1,
+ {
+ mode: authMode.value,
+ selectedPhoto: authProfilePhoto.value,
+ username: authUsername.value,
+ } satisfies AuthMediaContext,
+ )
+ void router.push({
+ path: `/apps/${app}`,
+ query: { mediaAttachment: 'photo' },
+ })
+}
+
function syncProfileDraft(): void {
profileDraft.value = {
avatarMediaId: pages.profile?.avatar_media_id ?? 0,
@@ -278,12 +371,16 @@ function syncProfileDraft(): void {
}
function ensureBrowserProfile(): void {
- const onboardingScenario = new URLSearchParams(window.location.search).get('testScenario')
+ const onboardingScenario = new URLSearchParams(window.location.search).get(
+ 'testScenario',
+ )
if (
!import.meta.env.DEV ||
- ['local-pages-onboarding', 'citymarkt-local-pages-account-missing'].includes(
- onboardingScenario ?? '',
- ) ||
+ [
+ 'local-pages-onboarding',
+ 'local-pages-register',
+ 'citymarkt-local-pages-account-missing',
+ ].includes(onboardingScenario ?? '') ||
pages.profile
)
return
@@ -318,7 +415,10 @@ function openProfileMedia(app: 'camera' | 'photos'): void {
1,
{ draft: { ...profileDraft.value } } satisfies ProfileMediaContext,
)
- void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
+ void router.push({
+ path: `/apps/${app}`,
+ query: { mediaAttachment: 'photo' },
+ })
}
function removeProfilePhoto(): void {
@@ -334,7 +434,8 @@ async function saveProfile(): Promise {
profilePending.value = true
try {
const response = await pages.saveProfile({
- avatarMediaId: selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
+ avatarMediaId:
+ selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
bio: profileDraft.value.bio.trim(),
handle: profileDraft.value.handle.trim().toLowerCase(),
})
@@ -391,7 +492,10 @@ function openMediaApp(app: 'camera' | 'photos'): void {
photos: [...pickedPhotos.value],
} satisfies MediaContext,
)
- void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
+ void router.push({
+ path: `/apps/${app}`,
+ query: { mediaAttachment: 'photo' },
+ })
}
async function publish(): Promise {
@@ -412,7 +516,13 @@ async function publish(): Promise {
showFeedback(`Apps.localPages.errors.${response.error ?? 'default'}`)
return
}
- draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
+ draft.value = {
+ body: '',
+ category: 'recommendation',
+ district: 'los_santos',
+ images: [],
+ title: '',
+ }
pickedPhotos.value = []
cityMarktListing.value = null
cityMarktListingId.value = null
@@ -443,22 +553,32 @@ async function react(kind: 'like' | 'save'): Promise {
showFeedback('Apps.localPages.errors.not_authenticated')
return
}
- const active = kind === 'like' ? !Boolean(selected.value.is_liked) : !Boolean(selected.value.is_saved)
+ const active =
+ kind === 'like'
+ ? !Boolean(selected.value.is_liked)
+ : !Boolean(selected.value.is_saved)
if (await pages.react(selected.value.id, kind, active)) {
if (kind === 'like') {
- selected.value.like_count = Math.max(0, selected.value.like_count + (active ? 1 : -1))
+ selected.value.like_count = Math.max(
+ 0,
+ selected.value.like_count + (active ? 1 : -1),
+ )
selected.value.is_liked = active
} else selected.value.is_saved = active
}
}
-async function reactToPost(post: PagesPost, kind: 'like' | 'save'): Promise {
+async function reactToPost(
+ post: PagesPost,
+ kind: 'like' | 'save',
+): Promise {
if (!isAuthenticated.value) {
showFeedback('Apps.localPages.errors.not_authenticated')
return
}
if (reactionPending.value) return
- const active = kind === 'like' ? !Boolean(post.is_liked) : !Boolean(post.is_saved)
+ const active =
+ kind === 'like' ? !Boolean(post.is_liked) : !Boolean(post.is_saved)
reactionPending.value = true
try {
await pages.react(post.id, kind, active)
@@ -477,7 +597,9 @@ async function removePost(): Promise {
function moveGallery(direction: number): void {
if (!selected.value?.images.length) return
- galleryIndex.value = (galleryIndex.value + direction + selected.value.images.length) % selected.value.images.length
+ galleryIndex.value =
+ (galleryIndex.value + direction + selected.value.images.length) %
+ selected.value.images.length
}
function openCityMarktListing(): void {
@@ -504,8 +626,15 @@ function sharePost(post: PagesPost): void {
}
onMounted(async () => {
- const selection = messageMedia.consumeMany('local-pages:compose')
- const profileSelection = messageMedia.consumeMany('local-pages:profile-avatar')
+ const selection = messageMedia.consumeMany(
+ 'local-pages:compose',
+ )
+ const profileSelection = messageMedia.consumeMany(
+ 'local-pages:profile-avatar',
+ )
+ const authSelection = messageMedia.consumeMany(
+ 'local-pages:auth-avatar',
+ )
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
@@ -513,11 +642,23 @@ onMounted(async () => {
}
for (const media of selection.media) {
const id = String(media.id)
- if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
+ if (draft.value.images.includes(id) || draft.value.images.length >= 6)
+ continue
draft.value.images.push(id)
- pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
+ pickedPhotos.value.push({
+ background: `url(${JSON.stringify(media.url)})`,
+ id,
+ })
}
}
+ if (authSelection) {
+ if (authSelection.context) {
+ authMode.value = authSelection.context.mode
+ authUsername.value = authSelection.context.username
+ authProfilePhoto.value = authSelection.context.selectedPhoto
+ }
+ if (authSelection.media[0]) authProfilePhoto.value = authSelection.media[0]
+ }
if (isAuthenticated.value) {
await pages.loadProfile()
ensureBrowserProfile()
@@ -530,7 +671,8 @@ onMounted(async () => {
await loadFeed()
}
if (profileSelection) {
- if (profileSelection.context) profileDraft.value = profileSelection.context.draft
+ if (profileSelection.context)
+ profileDraft.value = profileSelection.context.draft
if (profileSelection.media[0]) {
selectedProfilePhoto.value = profileSelection.media[0]
profileDraft.value.avatarMediaId = profileSelection.media[0].id
@@ -573,7 +715,11 @@ onMounted(async () => {
if (!listingId || cityMarktListingId.value) screen.value = 'compose'
}
const easyShareId = String(route.query.easyShareId ?? '')
- if (pages.profile?.exists && easyShareId && route.query.easyShareKind === 'post') {
+ if (
+ pages.profile?.exists &&
+ easyShareId &&
+ route.query.easyShareKind === 'post'
+ ) {
const response = await pages.get(easyShareId)
if (response.success && response.data) {
selected.value = response.data
@@ -592,22 +738,41 @@ onMounted(async () => {
:colors="{ bgIos: 'bg-transparent' }"
>
- {{ phone.t('Common.loading') }}
+
+ {{ phone.t('Common.loading') }}
+
- {{ phone.t('Apps.localPages.cityPulse') }}{{ phone.t('Apps.localPages.heroTitle') }}{{ phone.t('Apps.localPages.heroBody') }}
+
+
+ {{ phone.t('Apps.localPages.cityPulse') }}{{ phone.t('Apps.localPages.heroTitle') }}{{ phone.t('Apps.localPages.heroBody') }}
+
+
+
{
@clear="clearSearch"
@submit.prevent="loadFeed"
/>
- { category = value; loadFeed() }" />
+ {
+ category = value
+ loadFeed()
+ }
+ "
+ />
-
-
- {{ phone.t('Apps.localPages.authEyebrow') }}{{ phone.t('Apps.localPages.authWelcome') }}{{ phone.t('Apps.localPages.authBody') }}
-
-
- {{ phone.t('Apps.localPages.login') }}
- {{ phone.t('Apps.localPages.register') }}
-
-
+
-
+
-
{{ pages.profile.handle.charAt(0).toUpperCase() }}
-
{{ phone.t('Apps.localPages.localCreator') }}@{{ pages.profile.handle }}{{ pages.profile.post_count }} {{ phone.t('Apps.localPages.posts') }}{{ pages.profile.bio }}
-
+
{{
+ pages.profile.handle.charAt(0).toUpperCase()
+ }}
+
+
{{ phone.t('Apps.localPages.localCreator') }}@{{ pages.profile.handle }}{{ pages.profile.post_count }}
+ {{ phone.t('Apps.localPages.posts') }}
+
{{ pages.profile.bio }}
+
+
+
+
- {{ phone.t('Apps.localPages.myPosts') }}{{ phone.t('Apps.localPages.saved') }}
+
+
+ {{ phone.t('Apps.localPages.myPosts') }}
+ {{ phone.t('Apps.localPages.saved') }}
+
+
+
+
+
+ {{ phone.t('Common.signOut') }}
+
+
- {{ phone.t('Common.loading') }}
-
-
+
+ {{ phone.t('Common.loading') }}
+
+
+
-
-
{{ post.author_name.charAt(0).toUpperCase() }}@{{ post.author_name }} {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}
{{ label('categories', post.category) }}
- 1 / {{ post.images.length }}
- {{ post.title }}
{{ post.body }}
-
-
+
+
+
{{
+ post.author_name.charAt(0).toUpperCase()
+ }}
+
+ @{{ post.author_name }}
+ {{
+ post.district
+ ? phone.t(`Apps.citymarkt.districts.${post.district}`)
+ : phone.t('Apps.localPages.allLosSantos')
+ }}
+ · {{ relativeDate(post.created_at) }}
+
+
{{ label('categories', post.category) }}
+
+
+ 1 / {{ post.images.length }}
+
+ {{ post.title }}
+ {{ post.body }}
+
+
-
{{ phone.t('Apps.localPages.noPosts') }}{{ phone.t('Apps.localPages.noPostsBody') }}
+
+ {{
+ phone.t('Apps.localPages.noPosts')
+ }}{{ phone.t('Apps.localPages.noPostsBody') }}
+
@@ -764,16 +1135,29 @@ onMounted(async () => {
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('feed')"
>
- {{ phone.t('Apps.localPages.discover') }}
-
+ {{
+ phone.t('Apps.localPages.discover')
+ }}
+
- {{ phone.t('Apps.localPages.create') }}
-
+ {{
+ phone.t('Apps.localPages.create')
+ }}
+
{
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('profile')"
>
- {{ phone.t('Apps.localPages.profile') }}
-
+ {{
+ phone.t('Apps.localPages.profile')
+ }}
+
@@ -790,25 +1180,115 @@ onMounted(async () => {
-
+
{{ phone.t('Apps.localPages.post') }}
-
+
-
-
+
+
-
{{ galleryIndex + 1 }} / {{ selected.images.length }}
-
{{ selected.author_name.charAt(0).toUpperCase() }}@{{ selected.author_name }}{{ relativeDate(selected.created_at) }}
{{ label('categories', selected.category) }}{{ selected.title }}
{{ selected.body }}
{{ phone.t('Apps.localPages.location') }}{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}
{{ phone.t('Apps.localPages.sharedFrom') }}{{ phone.t('Apps.localPages.openCityMarkt') }}${{ Number(selected.citymarkt_price).toLocaleString() }}
+
+
+
+ {{ galleryIndex + 1 }} / {{ selected.images.length }}
+
+
+
+
{{
+ selected.author_name.charAt(0).toUpperCase()
+ }}
+
+ @{{ selected.author_name }}{{ relativeDate(selected.created_at) }}
+
+
{{ label('categories', selected.category) }}
+
+ {{ selected.title }}
+ {{ selected.body }}
+
+
+
+ {{ phone.t('Apps.localPages.location') }}{{
+ selected.district
+ ? phone.t(`Apps.citymarkt.districts.${selected.district}`)
+ : phone.t('Apps.localPages.allLosSantos')
+ }}
+
+
+
+ {{ phone.t('Apps.localPages.sharedFrom') }}{{
+ phone.t('Apps.localPages.openCityMarkt')
+ }}${{ Number(selected.citymarkt_price).toLocaleString() }}
+
+
- {{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}
- {{ phone.t('Apps.easyShare.share') }}
- {{ phone.t('Apps.localPages.save') }}
+
+ {{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}
+
+
+ {{ phone.t('Apps.easyShare.share') }}
+
+
+ {{ phone.t('Apps.localPages.save') }}
+
@@ -822,16 +1302,38 @@ onMounted(async () => {
center-title
left-class="pages-create-action pages-create-action--close !min-w-[58px] !h-11 !p-0 !rounded-full"
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
- :title="phone.t(cityMarktListing ? 'Apps.localPages.cityMarktComposeNavTitle' : 'Apps.localPages.shareWithCity')"
- :subtitle="phone.t(cityMarktListing ? 'Apps.localPages.categories.citymarkt' : 'Apps.localPages.newPost')"
+ :title="
+ phone.t(
+ cityMarktListing
+ ? 'Apps.localPages.cityMarktComposeNavTitle'
+ : 'Apps.localPages.shareWithCity',
+ )
+ "
+ :subtitle="
+ phone.t(
+ cityMarktListing
+ ? 'Apps.localPages.categories.citymarkt'
+ : 'Apps.localPages.newPost',
+ )
+ "
>
-
+
{{ phone.t('Common.close') }}
-
+
{{ phone.t('Apps.localPages.publish') }}
@@ -840,28 +1342,87 @@ onMounted(async () => {
{{ phone.t('Apps.localPages.cityMarktComposeTitle') }}{{ phone.t('Apps.localPages.cityMarktComposeHint') }}{{
+ phone.t('Apps.localPages.cityMarktComposeTitle')
+ }}{{
+ phone.t('Apps.localPages.cityMarktComposeHint')
+ }}
-
-
-
+
+
+
+
+
{{ phone.t('Apps.citymarkt.addPhotos') }}
- {{ phone.t(cityMarktListing ? 'Apps.localPages.cityMarktPhotosHint' : 'Apps.citymarkt.addPhotosBody') }}
+
+ {{
+ phone.t(
+ cityMarktListing
+ ? 'Apps.localPages.cityMarktPhotosHint'
+ : 'Apps.citymarkt.addPhotosBody',
+ )
+ }}
+
-
-
- {{ phone.t('Apps.citymarkt.chooseGallery') }}
- {{ phone.t('Apps.citymarkt.chooseGalleryBody') }}
-
-
-
- {{ phone.t('Apps.citymarkt.takePhotos') }}
- {{ phone.t('Apps.citymarkt.takePhotosBody') }}
-
+
+
+ {{ phone.t('Apps.citymarkt.chooseGallery') }}
+ {{ phone.t('Apps.citymarkt.chooseGalleryBody') }}
+
+
+
+ {{ phone.t('Apps.citymarkt.takePhotos') }}
+ {{ phone.t('Apps.citymarkt.takePhotosBody') }}
+
{{ phone.t('Apps.citymarkt.selectedPhotos') }}
@@ -877,57 +1438,1254 @@ onMounted(async () => {
:photo-label="phone.t('Apps.citymarkt.photo')"
/>
- {{ index + 1 }}
+
+ {{ index + 1 }}
+
- {{ feedback }}
+
+
+ {{ feedback }}
+
diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue
index f7b1a44..beb175c 100644
--- a/frontend/src/views/apps/SettingsApp.vue
+++ b/frontend/src/views/apps/SettingsApp.vue
@@ -57,6 +57,7 @@ import {
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
+import { useAppAuthStore } from '@/stores/app-auth'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -132,6 +133,7 @@ const FRAME_PICKER_GAP = 8
const phone = usePhoneStore()
const account = useAccountStore()
+const appAuth = useAppAuthStore()
const query = ref('')
const activeView = ref('root')
const selectedNotificationAppId = ref('calculator')
@@ -602,6 +604,7 @@ async function submitAccount(): Promise {
async function logoutAccount(): Promise {
if (!(await account.logout())) accountToast.value = accountError()
+ else appAuth.clear()
}
function requestRemoveDevice(imei: string): void {
@@ -650,6 +653,7 @@ async function confirmFactoryReset(): Promise {
factoryResetProgress.value = 100
factoryResetting.value = false
if (!success) accountToast.value = accountError()
+ else appAuth.hydrate(undefined, '')
}
async function confirmSimEject(): Promise {
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index 34f8cf6..f2b77f8 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -144,6 +144,8 @@ let mockMapMarkers = [
]
let crewLinkProfile = {
activeGroupId: 'crewlink-group-night-shift',
+ avatarMediaId: 1,
+ avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
id: 'crewlink-profile-skyline',
mapVisible: true,
overheadVisible: false,
@@ -176,6 +178,7 @@ const crewLinkMembers = {
'crewlink-group-night-shift': [
{
coords: { x: -155.2, y: -1005.8, z: 28.4 },
+ avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
id: 'crewlink-profile-skyline',
joinedAt: Date.now() - 36 * 86400000,
mapVisible: true,
@@ -350,7 +353,10 @@ const crewLinkLimits = {
}
function crewLinkBootstrap(testScenario = '') {
- if (testScenario === 'crewlink-onboarding') {
+ if (
+ testScenario === 'crewlink-onboarding' ||
+ (testScenario === 'crewlink-register' && !crewLinkProfile)
+ ) {
return { groups: [], invitations: [], profile: null }
}
if (testScenario === 'crewlink-empty') {
@@ -1985,6 +1991,14 @@ let calendarEvents = [
},
]
const deviceData = {
+ appAuth: {
+ payload: {
+ accountEmail: 'demo@ifruit.com',
+ signedIn: ['citymarkt', 'local-pages', 'feather', 'crewlink'],
+ version: 1,
+ },
+ revision: 1,
+ },
alarms: {
payload: [
{
@@ -3881,7 +3895,8 @@ const easyShareCatalog = [
appId: 'citymarkt',
copyText: 'Comet Retro Custom in excellent condition.',
id: 'listing-easyshare-comet',
- imageUrl: 'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
+ imageUrl:
+ 'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
kind: 'link',
link: 'skyphone://citymarkt/listing/listing-easyshare-comet',
subtitle: '$84,000',
@@ -3905,7 +3920,8 @@ const easyShareCatalog = [
appId: 'photos',
copyText: 'Sunset over Los Santos.',
id: 3,
- imageUrl: 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
+ imageUrl:
+ 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
kind: 'photo',
link: 'skyphone://media/3',
title: 'Los Santos sunset',
@@ -3960,7 +3976,8 @@ const easyShareCatalog = [
appId: 'photos',
copyText: 'Vehicle walkaround video.',
id: 7,
- imageUrl: 'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
+ imageUrl:
+ 'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
kind: 'video',
link: 'skyphone://media/7',
title: 'Vehicle walkaround',
@@ -4453,6 +4470,10 @@ app.post('/api/:endpoint', (request, response) => {
}
crewLinkProfile = {
activeGroupId: null,
+ avatarMediaId: Number(request.body.avatarMediaId) || null,
+ avatarUrl:
+ mockMedia.find((item) => item.id === Number(request.body.avatarMediaId))
+ ?.url ?? null,
id: `crewlink-profile-${Date.now()}`,
mapVisible: true,
overheadVisible: false,
@@ -4462,8 +4483,25 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'crewlink:update-profile') {
+ const hasAvatarUpdate = request.body.avatarMediaId !== undefined
+ const avatarMediaId = Number(request.body.avatarMediaId) || null
+ const avatar = hasAvatarUpdate
+ ? mockMedia.find(
+ (item) => item.id === avatarMediaId && item.mediaType === 'photo',
+ )
+ : null
+ if (avatarMediaId && !avatar) {
+ response.json({ success: false, error: 'invalid_profile_image' })
+ return
+ }
crewLinkProfile = {
...crewLinkProfile,
+ avatarMediaId: hasAvatarUpdate
+ ? avatarMediaId
+ : crewLinkProfile.avatarMediaId,
+ avatarUrl: hasAvatarUpdate
+ ? (avatar?.url ?? null)
+ : crewLinkProfile.avatarUrl,
mapVisible: request.body.mapVisible === true,
overheadVisible: request.body.overheadVisible === true,
username: String(request.body.username ?? crewLinkProfile.username),
@@ -4471,6 +4509,7 @@ app.post('/api/:endpoint', (request, response) => {
for (const members of Object.values(crewLinkMembers)) {
const own = members.find((member) => member.id === crewLinkProfile.id)
if (own) {
+ own.avatarUrl = crewLinkProfile.avatarUrl
own.mapVisible = crewLinkProfile.mapVisible
own.overheadVisible = crewLinkProfile.overheadVisible
own.username = crewLinkProfile.username
@@ -4718,7 +4757,7 @@ app.post('/api/:endpoint', (request, response) => {
success: true,
data: {
onboarded: featherOnboarded,
- profile: featherProfiles[0],
+ profile: featherOnboarded ? featherProfiles[0] : null,
feed: {
items: empty
? []
@@ -4764,6 +4803,22 @@ app.post('/api/:endpoint', (request, response) => {
featherProfiles[0].display_name = displayName
featherProfiles[0].handle = handle
featherProfiles[0].bio = bio
+ const avatar = mockMedia.find(
+ (item) =>
+ item.id === Number(request.body.avatarId) && item.mediaType === 'photo',
+ )
+ if (request.body.avatarId && !avatar) {
+ response.json({ success: false, error: 'invalid_media' })
+ return
+ }
+ featherProfiles[0].avatar_url = avatar?.url ?? null
+ featherPosts
+ .filter((post) => post.profile_id === featherProfiles[0].id)
+ .forEach((post) => {
+ post.avatar_url = featherProfiles[0].avatar_url
+ post.display_name = displayName
+ post.handle = handle
+ })
featherOnboarded = true
response.json({ success: true })
return
@@ -5388,8 +5443,7 @@ app.post('/api/:endpoint', (request, response) => {
mediaDurationMs: request.body.mediaDurationMs ?? null,
mediaUrl: messageType === 'text' ? null : mediaUrl,
messageType,
- sharePayload:
- messageType === 'share' ? request.body.sharePayload : null,
+ sharePayload: messageType === 'share' ? request.body.sharePayload : null,
}
flareMessages[match.id] ??= []
flareMessages[match.id].push(message)
@@ -6759,8 +6813,7 @@ app.post('/api/:endpoint', (request, response) => {
replyToId: request.body.replyToId,
replyBody: reply?.body,
reactions: {},
- sharePayload:
- messageType === 'share' ? request.body.sharePayload : null,
+ sharePayload: messageType === 'share' ? request.body.sharePayload : null,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
readAt: null,
}
@@ -6995,15 +7048,69 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'development:bootstrap') {
- if (testScenario === 'feather-onboarding') featherOnboarded = false
+ authenticated = true
+ linkedAccount = {
+ devices: accountDevices,
+ email: 'demo@ifruit.com',
+ id: 1,
+ }
+ if (
+ testScenario === 'feather-onboarding' ||
+ testScenario === 'feather-register'
+ )
+ featherOnboarded = false
else featherOnboarded = true
+ pagesOnboardingCompleted = ![
+ 'local-pages-onboarding',
+ 'local-pages-register',
+ 'citymarkt-local-pages-account-missing',
+ ].includes(testScenario)
+ if (testScenario === 'crewlink-register') {
+ crewLinkProfile = null
+ } else {
+ crewLinkProfile = {
+ activeGroupId: 'crewlink-group-night-shift',
+ avatarMediaId: 1,
+ avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
+ id: 'crewlink-profile-skyline',
+ mapVisible: true,
+ overheadVisible: false,
+ username: 'Skyline',
+ }
+ }
+ if (testScenario === 'citymarkt-register') {
+ marketplaceProfile = {
+ avatar_media_id: null,
+ avatar_url: null,
+ bio: '',
+ display_name: '',
+ email: linkedAccount?.email ?? 'demo@ifruit.com',
+ exists: false,
+ listing_count: 0,
+ }
+ } else {
+ marketplaceProfile = {
+ avatar_media_id: 1,
+ avatar_url: 'https://picsum.photos/seed/citymarkt-demo-avatar/240/240',
+ bio: 'Fair prices, quick replies, and meetups anywhere in Los Santos.',
+ display_name: 'Skyline Deals',
+ email: linkedAccount?.email ?? 'demo@ifruit.com',
+ exists: true,
+ listing_count: marketplaceListings.filter(
+ (listing) => listing.seller_account_id === 1,
+ ).length,
+ }
+ }
response.json({
success: true,
data: {
- account: testScenario === 'feather-login' ? null : linkedAccount,
+ account: linkedAccount,
device: {
data:
- testScenario.startsWith('citymarkt-')
+ testScenario.startsWith('citymarkt-') ||
+ testScenario.startsWith('feather-') ||
+ testScenario.startsWith('local-pages-') ||
+ testScenario.startsWith('crewlink-')
? {
...deviceData,
apps: {
@@ -7012,10 +7119,11 @@ app.post('/api/:endpoint', (request, response) => {
...deviceData.apps.payload,
homeLayout: {
dock: [],
- grid:
- testScenario === 'citymarkt-local-pages-missing'
- ? []
- : ['local-pages'],
+ grid: testScenario.startsWith('crewlink-')
+ ? ['crewlink']
+ : testScenario === 'citymarkt-local-pages-missing'
+ ? ['citymarkt']
+ : ['citymarkt', 'local-pages'],
hidden:
testScenario === 'citymarkt-local-pages-missing'
? ['local-pages']
@@ -7024,6 +7132,31 @@ app.post('/api/:endpoint', (request, response) => {
},
},
},
+ appAuth: [
+ 'citymarkt-login',
+ 'citymarkt-register',
+ 'feather-login',
+ 'feather-register',
+ 'local-pages-login',
+ 'local-pages-register',
+ 'crewlink-login',
+ 'crewlink-register',
+ ].includes(testScenario)
+ ? {
+ payload: {
+ accountEmail: linkedAccount?.email ?? '',
+ signedIn: testScenario.startsWith('feather-')
+ ? ['citymarkt', 'local-pages', 'crewlink']
+ : testScenario.startsWith('local-pages-')
+ ? ['citymarkt', 'feather', 'crewlink']
+ : testScenario.startsWith('crewlink-')
+ ? ['citymarkt', 'local-pages', 'feather']
+ : ['local-pages', 'feather', 'crewlink'],
+ version: 1,
+ },
+ revision: deviceData.appAuth?.revision ?? 0,
+ }
+ : deviceData.appAuth,
}
: deviceData,
imei: '356938035643809',
@@ -7151,11 +7284,11 @@ app.post('/api/:endpoint', (request, response) => {
organization: selectedContact.organization ?? null,
phone_number: selectedContact.phone_number,
}
- : messageType === 'share'
- ? request.body.sharePayload
- : isAttachment
- ? attachmentId
- : null,
+ : messageType === 'share'
+ ? request.body.sharePayload
+ : isAttachment
+ ? attachmentId
+ : null,
media_waveform:
messageType === 'voice' ? request.body.mediaWaveform : null,
message_type: messageType,
@@ -7575,9 +7708,11 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'pages:profile') {
const email = linkedAccount?.email ?? pagesProfile.email
const onboarding =
- ['local-pages-onboarding', 'citymarkt-local-pages-account-missing'].includes(
- testScenario,
- ) && !pagesOnboardingCompleted
+ [
+ 'local-pages-onboarding',
+ 'local-pages-register',
+ 'citymarkt-local-pages-account-missing',
+ ].includes(testScenario) && !pagesOnboardingCompleted
response.json({
success: true,
data: {
@@ -7597,9 +7732,12 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'pages:profile-save') {
pagesOnboardingCompleted = true
const avatarMediaId = Number(request.body.avatarMediaId) || 0
- const avatarMedia = avatarMediaId > 0
- ? mockMedia.find((item) => item.id === avatarMediaId && item.mediaType === 'photo')
- : null
+ const avatarMedia =
+ avatarMediaId > 0
+ ? mockMedia.find(
+ (item) => item.id === avatarMediaId && item.mediaType === 'photo',
+ )
+ : null
if (avatarMediaId > 0 && !avatarMedia) {
response.json({ success: false, error: 'invalid_profile_image' })
return
@@ -7610,7 +7748,9 @@ app.post('/api/:endpoint', (request, response) => {
bio: String(request.body.bio ?? '').trim(),
email: linkedAccount?.email ?? pagesProfile.email,
exists: true,
- handle: String(request.body.handle ?? '').trim().toLowerCase(),
+ handle: String(request.body.handle ?? '')
+ .trim()
+ .toLowerCase(),
}
pagesPosts.forEach((post) => {
if (post.account_id === 1) post.author_name = pagesProfile.handle
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 76fe035..3334da8 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -69,6 +69,18 @@ Locales["en"] = {
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
+ signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
+ signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.",
+ appAuth = {
+ eyebrow = "iFruit account", title = "Continue to {app}", body = "Use your iFruit email and password. This login applies only to {app}.",
+ login = "Login", register = "Register", email = "iFruit email", password = "Password", confirm = "Confirm password",
+ loginAction = "Log in", registerAction = "Create account",
+ errors = {
+ invalid_email = "Enter a valid iFruit email.", invalid_password = "Password must be 6–64 characters.",
+ invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit email is already registered.",
+ rate_limited = "Too many attempts. Try again in a minute.", default = "The account request failed.",
+ },
+ },
},
ControlCenter = {
airplaneMode = "Airplane Mode", bluetooth = "Bluetooth", brightness = "Brightness", calculator = "Calculator",
@@ -124,6 +136,15 @@ Locales["en"] = {
crewlink = {
name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network",
signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings",
+ authEyebrow = "Private crew network", authTitle = "Welcome to CrewLink",
+ authBody = "Your iFruit email is linked automatically. Use your CrewLink username to continue.",
+ login = "Log in", register = "Register", ifruitEmail = "iFruit address", gallery = "Gallery", camera = "Camera", backToLogin = "Back to CrewLink login",
+ authErrors = {
+ no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.",
+ profile_not_found = "No CrewLink profile exists for this iFruit email.", profile_exists = "This iFruit email already has a CrewLink profile.",
+ username_taken = "That CrewLink username is already taken.", invalid_profile_image = "Choose a valid photo from this phone.",
+ rate_limited = "Too many attempts. Try again shortly.", request_failed = "CrewLink could not complete the request.",
+ },
welcomeEyebrow = "Your crew. One signal.", welcomeTitle = "Find your people", welcomeBody = "Choose a private CrewLink username. Only confirmed group members can see your presence and shared location.",
username = "CrewLink username", usernamePlaceholder = "e.g. Skyline", createProfile = "Create CrewLink Profile", profileCreated = "Your CrewLink profile is ready.", privacyNote = "Private by design. No public player search.",
noGroupEyebrow = "No active crew", noGroupTitle = "Build your private network", noGroupBody = "Create a crew or join friends with a private invitation code.",
@@ -142,7 +163,8 @@ Locales["en"] = {
roleDescriptions = { coordinator = "Manage invitations, settings, roles, and pings.", moderator = "Invite nearby users and moderate group pings.", member = "Share presence and create pings when enabled.", guest = "View the active crew without coordination permissions." },
manageCrew = "Manage Crew", manageCrewBody = "Update identity, permissions, and invitations.", memberPings = "Member pings", memberPingsBody = "Allow members and guests to create pings.", allowOverhead = "Overhead labels", allowOverheadBody = "Permit nearby opt-in member labels in the world.", groupSaved = "Crew settings saved.",
assignRole = "Assign Role", roleUpdated = "Member role updated.", transferOwnership = "Transfer Ownership", removeMember = "Remove from Crew", yourCrewLinkId = "Your CrewLink ID", privacyVisibility = "Privacy & Visibility", shareOnMap = "Share on group map", shareOnMapBody = "Active members can see your live position.", overheadLabels = "Nearby overhead label", overheadLabelsBody = "Show your CrewLink name to nearby opted-in members.",
- yourGroups = "Your Groups", createAnotherGroup = "Create another group", account = "CrewLink Account", externalApi = "Connected resources", externalApiBody = "Approved scripts may add temporary group pings.", editUsername = "Edit Username", editUsernameBody = "Your username is unique across CrewLink.", profileSaved = "CrewLink profile saved.", deleteGroup = "Delete Group", leaveGroup = "Leave Group", confirmAction = "Confirm",
+ yourGroups = "Your Groups", createAnotherGroup = "Create another group", account = "CrewLink Account", externalApi = "Connected resources", externalApiBody = "Approved scripts may add temporary group pings.", editUsername = "Edit Username", editUsernameBody = "Your username is unique across CrewLink.",
+ editProfile = "Edit CrewLink Profile", editProfileBody = "Change your username and profile photo.", removeProfilePhoto = "Remove profile photo", profileSaved = "CrewLink profile saved.", deleteGroup = "Delete Group", leaveGroup = "Leave Group", confirmAction = "Confirm",
confirm = {
["delete-group"] = { title = "Delete this crew?", body = "The group, memberships, invitations, and active pings will be permanently removed." },
["leave-group"] = { title = "Leave this crew?", body = "You will lose access to its members, map, and pings." },
@@ -152,7 +174,7 @@ Locales["en"] = {
["delete-groupDone"] = "Crew deleted.", ["leave-groupDone"] = "You left the crew.", ["remove-memberDone"] = "Member removed.", ["transfer-ownerDone"] = "Ownership transferred.",
notifications = { invite = "{actor} invited you to {group}.", member_joined = "{actor} joined {group}.", ping = "{actor} shared '{ping}' with {group}.", role = "Your CrewLink role changed in {group}.", removed = "You were removed from {group}.", default = "Your CrewLink group has an update." },
errors = {
- not_authenticated = "Sign in to your iFruit Account first.", profile_required = "Create your CrewLink profile first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_profile = "Check your profile details.", username_taken = "That CrewLink username is already taken.",
+ not_authenticated = "Sign in to your iFruit Account first.", profile_required = "Create your CrewLink profile first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_profile = "Check your profile details.", invalid_profile_image = "Choose a valid photo from this phone.", username_taken = "That CrewLink username is already taken.",
invalid_group = "Choose a valid group name and signal colour.", group_limit = "You have reached your group limit.", member_limit = "This group has reached its member limit.", group_not_found = "This group is no longer available.", invalid_code = "That invitation code is invalid.", already_member = "You are already a member of this group.", forbidden = "Your current role cannot perform this action.",
player_not_nearby = "That player is no longer nearby.", player_unavailable = "That player cannot receive a CrewLink invitation.", invalid_invitation = "This invitation is invalid.", invitation_expired = "This invitation has expired.", invalid_role = "That role cannot be assigned.", owner_must_transfer = "Transfer ownership before leaving this group.",
invalid_ping = "Choose a valid ping type, label, and position.", ping_limit = "This crew already has too many active pings.", ping_not_found = "That ping is no longer active.", rate_limited = "Too many CrewLink actions. Try again shortly.", request_failed = "CrewLink is temporarily unavailable.",
@@ -264,19 +286,19 @@ Locales["en"] = {
name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back",
settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.",
forYou = "For you", following = "Following", add = "Add", network = "Network", networkTitle = "Find your people", networkBody = "Follow voices from across Los Santos and build a timeline that feels like yours.", networkSearchPlaceholder = "Search names or @usernames", postSearchPlaceholder = "Search posts or #hashtags", searchResults = "Search results", suggestedPeople = "Suggested for you", noPeopleFound = "No people found", noPeopleFoundBody = "Try another name or username.", noSuggestions = "No suggestions yet", noSuggestionsBody = "New Feather accounts will appear here.", all = "All", mentions = "Mentions", media = "Media", trending = "Trending", peopleTab = "People", searchPlaceholder = "Search Feather", search = "Search", cancel = "Cancel", done = "Done",
- authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "One iFruit account. Every conversation, wherever you sign in.",
- login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Log in with your iFruit address to continue to Feather.",
- registerTitle = "Create your iFruit account", registerBody = "Choose an address and secure it with a password. Your Feather profile comes next.",
+ authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "Your iFruit email is linked automatically. Use your Feather username to continue.",
+ login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Use your Feather username. Your iFruit email is linked automatically.",
+ registerTitle = "Create your Feather profile", registerBody = "Choose a username and optional profile photo. No password is needed.",
email = "iFruit address", emailPlaceholder = "yourname", password = "Password", passwordPlaceholder = "6–64 characters",
confirmPassword = "Confirm password", confirmPasswordPlaceholder = "Enter it again", showPassword = "Show password", hidePassword = "Hide password",
- loginAction = "Continue to Feather", registerAction = "Create account", noAccount = "New to Feather?", haveAccount = "Already registered?",
- registerNow = "Create an account", loginNow = "Log in", authTrust = "Your credentials are verified by the server and this phone is linked to your iFruit account.",
+ loginAction = "Continue to Feather", registerAction = "Create profile", noAccount = "New to Feather?", haveAccount = "Already registered?",
+ registerNow = "Create a profile", loginNow = "Log in", authTrust = "This Feather session is separate from your other apps and linked to your iFruit email.",
profileStep = "Step 2 of 2", accountConnected = "iFruit account connected",
authErrors = {
- invalid_email = "Enter a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.",
- password_mismatch = "The passwords do not match.", invalid_credentials = "The iFruit address or password is incorrect.",
- email_taken = "That iFruit address is already registered.", rate_limited = "Too many attempts. Try again in a minute.",
- default = "The account request failed. Please try again.",
+ no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_handle = "Use 3-30 letters, numbers or underscores.", invalid_username = "That username does not match your Feather profile.",
+ profile_not_found = "No Feather profile exists for this iFruit email.", already_registered = "This iFruit email already has a Feather profile.",
+ handle_taken = "That Feather username is already taken.", invalid_media = "Choose a valid photo from this phone.",
+ rate_limited = "Too many attempts. Try again in a minute.", default = "Feather could not complete the request. Please try again.",
},
welcome = "Find your voice", welcomeBody = "Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.",
createProfile = "Create Feather profile", profileDetailsHint = "Choose how people will recognize you.", displayName = "Display name", displayNamePlaceholder = "Your name", handle = "Username", handlePlaceholder = "your_username", handleHint = "3-30 letters, numbers or underscores",
@@ -1197,7 +1219,16 @@ Locales["en"] = {
noListingsBody = "Try another search or category.", noDistrict = "No district",
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
- signInTitle = "Sign in to iFruit", signInBody = "Use Settings to sign in before selling, saving or messaging.",
+ signInTitle = "Sign in to iFruit", signInBody = "Log in to your CityMarkt profile to sell, save or message.",
+ authEyebrow = "CityMarkt account", authTitle = "Welcome to CityMarkt",
+ authBody = "Your iFruit email is linked automatically. Use your CityMarkt username to continue.",
+ login = "Login", register = "Register", authUsername = "Username",
+ authErrors = {
+ no_ifruit_account = "Connect an iFruit account in Settings first.",
+ invalid_username = "Enter the username of your CityMarkt profile.",
+ profile_not_found = "No CityMarkt profile exists for this iFruit email.",
+ profile_exists = "A CityMarkt profile already exists. Use Login instead.",
+ },
noMessages = "No conversations", noMessagesBody = "Messages about offers will appear here.",
myListings = "My listings", favorites = "Favorites", addFavorite = "Add to favorites", removeFavorite = "Remove from favorites", noProfileListings = "Nothing here yet",
createProfile = "Create your CityMarkt profile", editProfile = "Edit profile",
@@ -1254,7 +1285,7 @@ Locales["en"] = {
noPosts = "Nothing here yet", noPostsBody = "Be the first to share something with the city.", noPhoto = "No photo attached",
signInTitle = "Your Local Pages profile", signInBody = "Sign in to iFruit in Settings to publish and save posts.",
authEyebrow = "Local Pages account", authWelcome = "Welcome to Local Pages",
- authBody = "Sign in or create an iFruit account to build your local profile.", login = "Sign in", register = "Register",
+ authBody = "Your iFruit email is linked automatically. Use your Local Pages username to continue.", login = "Sign in", register = "Register",
loginTitle = "Continue with iFruit", loginBody = "Your posts, saved items and profile stay linked to your account.",
registerTitle = "Create an iFruit account", registerBody = "Choose your new iFruit address. Your Local Pages profile comes next.",
authEmail = "iFruit address", authEmailPlaceholder = "your.name", authPassword = "Password",
@@ -1274,7 +1305,13 @@ Locales["en"] = {
cityMarktAppMissing = "Local Pages is not installed", cityMarktInstallHint = "Install Local Pages to share this listing",
cityMarktAccountMissing = "Local Pages profile required", cityMarktAccountHint = "Create a Local Pages profile before sharing",
cityMarktComposeTitle = "Share CityMarkt listing", cityMarktComposeNavTitle = "Share listing", cityMarktComposeHint = "Review the listing and publish it when you are ready.", cityMarktPhotosHint = "The CityMarkt listing photos will be included.",
- authErrors = { invalid_email = "Enter a valid 3-32 character iFruit address.", invalid_password = "Use a password between 6 and 64 characters.", invalid_credentials = "The iFruit address or password is incorrect.", email_taken = "This iFruit address is already registered.", password_mismatch = "The passwords do not match.", rate_limited = "Too many attempts. Try again shortly.", default = "The iFruit account request failed." },
+ authErrors = {
+ no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-24 lowercase letters, numbers, dots or underscores.",
+ profile_not_found = "No Local Pages profile exists for this iFruit email.", profile_exists = "This iFruit email already has a Local Pages profile.",
+ invalid_profile = "Check your Local Pages username.", invalid_profile_image = "Choose a valid photo from this phone.",
+ profile_handle_taken = "This Local Pages username is already taken.", rate_limited = "Too many attempts. Try again shortly.",
+ default = "Local Pages could not complete the request.",
+ },
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to iFruit first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
},
map = {
diff --git a/sky_phone/source/server/crewlink.lua b/sky_phone/source/server/crewlink.lua
index 0bb5cfe..7019ffd 100644
--- a/sky_phone/source/server/crewlink.lua
+++ b/sky_phone/source/server/crewlink.lua
@@ -82,6 +82,8 @@ local function profile_dto(row)
return {
id = row.id,
username = row.username,
+ avatarMediaId = row.avatar_media_id and tonumber(row.avatar_media_id) or nil,
+ avatarUrl = row.avatar_url,
activeGroupId = row.active_group_id,
mapVisible = tonumber(row.map_visible) == 1,
overheadVisible = tonumber(row.overhead_visible) == 1,
@@ -94,9 +96,11 @@ local function require_profile(source)
return nil, error_response
end
local rows = Bridge.Database.Query([[
- SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
- FROM `sky_phone_crewlink_profiles`
- WHERE `account_id` = ?
+ SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
+ p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
+ FROM `sky_phone_crewlink_profiles` p
+ LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
+ WHERE p.`account_id` = ?
LIMIT 1
]], { account.id })
if not rows[1] then
@@ -131,9 +135,10 @@ end
local function member_dtos(group_id)
local rows = Bridge.Database.Query([[
SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`,
- m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
+ avatar.`url` AS `avatar_url`, m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
FROM `sky_phone_crewlink_memberships` m
JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id`
+ LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE m.`group_id` = ?
ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at`
]], { group_id })
@@ -141,8 +146,10 @@ local function member_dtos(group_id)
row.account_id = tonumber(row.account_id)
row.mapVisible = tonumber(row.map_visible) == 1
row.overheadVisible = tonumber(row.overhead_visible) == 1
+ row.avatarUrl = row.avatar_url
row.map_visible = nil
row.overhead_visible = nil
+ row.avatar_url = nil
row.joinedAt = (tonumber(row.joined_at) or 0) * 1000
row.joined_at = nil
end
@@ -397,8 +404,11 @@ Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source)
return error_response
end
local rows = Bridge.Database.Query([[
- SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
- FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1
+ SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
+ p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
+ FROM `sky_phone_crewlink_profiles` p
+ LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
+ WHERE p.`account_id` = ? LIMIT 1
]], { account.id })
if not rows[1] then
return { success = true, data = { profile = nil, groups = {}, invitations = {} } }
@@ -415,14 +425,22 @@ Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source,
if not account then
return error_response
end
- local username = valid_username(data and data.username)
+ data = type(data) == "table" and data or {}
+ local username = valid_username(data.username)
if not username then
return { success = false, error = "invalid_username" }
end
+ local avatar_media_id = tonumber(data.avatarMediaId) or 0
+ if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
+ return { success = false, error = "invalid_profile_image" }
+ end
+ if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
+ return { success = false, error = "invalid_profile_image" }
+ end
local result = Bridge.Database.Query([[
- INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`)
- VALUES (?, ?, ?)
- ]], { new_id(), account.id, username })
+ INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `avatar_media_id`)
+ VALUES (?, ?, ?, NULLIF(?, 0))
+ ]], { new_id(), account.id, username, avatar_media_id })
if affected_rows(result) ~= 1 then
return { success = false, error = "username_taken" }
end
@@ -442,11 +460,26 @@ Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source,
if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then
return { success = false, error = "invalid_profile" }
end
+ local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
+ if data.avatarMediaId ~= nil then
+ local submitted_avatar_id = tonumber(data.avatarMediaId)
+ if not submitted_avatar_id or submitted_avatar_id < 0
+ or submitted_avatar_id ~= math.floor(submitted_avatar_id)
+ then
+ return { success = false, error = "invalid_profile_image" }
+ end
+ if submitted_avatar_id > 0
+ and not SkyPhoneMedia.ResolveOwnedMedia(source, submitted_avatar_id, "photo")
+ then
+ return { success = false, error = "invalid_profile_image" }
+ end
+ avatar_media_id = submitted_avatar_id > 0 and submitted_avatar_id or nil
+ end
local result = Bridge.Database.Query([[
UPDATE IGNORE `sky_phone_crewlink_profiles`
- SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?
+ SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?, `avatar_media_id` = ?
WHERE `id` = ?
- ]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, profile.id })
+ ]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, avatar_media_id, profile.id })
if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then
return { success = false, error = "username_taken" }
end
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 1cf8125..8d91d03 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -2098,6 +2098,7 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "username", type = "VARCHAR(20) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
+ { name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "active_group_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "map_visible", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "overhead_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" },
@@ -2111,9 +2112,11 @@ local schema = {
},
indexes = {
{ name = "idx_sky_phone_crewlink_active", columns = "(`active_group_id`)" },
+ { name = "idx_sky_phone_crewlink_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
+ { column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua
index 91d0a0f..29ad218 100644
--- a/sky_phone/source/server/phone.lua
+++ b/sky_phone/source/server/phone.lua
@@ -24,6 +24,7 @@ local allowed_device_namespaces = {
notifications = true,
wallpaper = true,
alarms = true,
+ appAuth = true,
apps = true,
games = true,
widgets = true,
diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql
index 1bf67a1..9605fa6 100644
--- a/sky_phone/sql/install.sql
+++ b/sky_phone/sql/install.sql
@@ -897,6 +897,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`username` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
+ `avatar_media_id` BIGINT UNSIGNED NULL,
`active_group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`map_visible` TINYINT(1) NOT NULL DEFAULT 1,
`overhead_visible` TINYINT(1) NOT NULL DEFAULT 0,
@@ -906,7 +907,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
UNIQUE KEY `uniq_sky_phone_crewlink_account` (`account_id`),
UNIQUE KEY `uniq_sky_phone_crewlink_username` (`username`),
KEY `idx_sky_phone_crewlink_active` (`active_group_id`),
- FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
+ KEY `idx_sky_phone_crewlink_avatar` (`avatar_media_id`),
+ FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
+ FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` (
From ce6393ba072ad65e2c87f180674498182903bb5f Mon Sep 17 00:00:00 2001
From: Dominik
Date: Wed, 12 Aug 2026 23:58:36 +0200
Subject: [PATCH 44/63] ADD - implement Weazel News app
---
README.md | 20 +
.../src/assets/img/app-icons/weazel-news.svg | 19 +
frontend/src/config/apps.test.ts | 14 +
frontend/src/config/apps.ts | 16 +
frontend/src/stores/phone.ts | 130 +
frontend/src/stores/weazel-news.test.ts | 212 ++
frontend/src/stores/weazel-news.ts | 283 ++
frontend/src/types/apps.ts | 1 +
frontend/src/types/weazel-news.ts | 76 +
frontend/src/utils/preferences.ts | 1 +
frontend/src/views/apps/weazel-news-app.vue | 2308 +++++++++++++++++
frontend/testserver/index.cjs | 359 +++
sky_phone/config/locales/en.lua | 125 +
sky_phone/config/weazel_news.lua | 28 +
sky_phone/fxmanifest.lua | 2 +
sky_phone/source/client/main.lua | 7 +
sky_phone/source/server/db_migrate.lua | 32 +
sky_phone/source/server/weazel_news.lua | 597 +++++
sky_phone/sql/install.sql | 25 +
19 files changed, 4255 insertions(+)
create mode 100644 frontend/src/assets/img/app-icons/weazel-news.svg
create mode 100644 frontend/src/stores/weazel-news.test.ts
create mode 100644 frontend/src/stores/weazel-news.ts
create mode 100644 frontend/src/types/weazel-news.ts
create mode 100644 frontend/src/views/apps/weazel-news-app.vue
create mode 100644 sky_phone/config/weazel_news.lua
create mode 100644 sky_phone/source/server/weazel_news.lua
diff --git a/README.md b/README.md
index 4b8324a..5c97335 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,26 @@ For a fresh manual database installation, import `sky_phone/sql/install.sql`. It
Framework, inventory, callback, notification, and database integrations live under `sky_phone/source/bridge`. The resource has no dependency on any other Sky resource.
+## Weazel News app
+
+The built-in Weazel News app publishes articles for every phone user and exposes its editorial
+desk only to server-authorized jobs. Configure the job-to-minimum-grade map in
+`sky_phone/config/weazel_news.lua`; unlisted jobs remain read-only:
+
+```lua
+AllowedJobs = {
+ weazel = 0,
+ reporter = 2,
+}
+```
+
+Every create, update, and delete request rechecks the player's current framework job and grade on
+the server. Authorized editorial jobs share the newsroom and can manage drafts and published
+articles. Cover images must come from the current phone's Gallery, changes use revision checks, and
+deletion is retained as an audit-safe soft delete. Runtime migration creates
+`sky_phone_weazel_articles` automatically; fresh installations receive the same schema through
+`sky_phone/sql/install.sql`.
+
## Radio app
The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically.
diff --git a/frontend/src/assets/img/app-icons/weazel-news.svg b/frontend/src/assets/img/app-icons/weazel-news.svg
new file mode 100644
index 0000000..c98d991
--- /dev/null
+++ b/frontend/src/assets/img/app-icons/weazel-news.svg
@@ -0,0 +1,19 @@
+
diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts
index 4a76207..09fd166 100644
--- a/frontend/src/config/apps.test.ts
+++ b/frontend/src/config/apps.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
+import { Newspaper } from 'lucide-vue-next'
+
import { isPhoneAppId, PHONE_APPS } from './apps'
describe('app registry', () => {
it('has unique ids and routes with the reference dock order', () => {
@@ -58,6 +60,16 @@ describe('app registry', () => {
labelKey: 'Apps.companies.name',
route: '/apps/companies',
})
+ expect(PHONE_APPS.find((app) => app.id === 'weazel-news')).toMatchObject({
+ category: 'social',
+ dockOrder: null,
+ gridOrder: 29,
+ labelKey: 'Apps.weazelNews.name',
+ route: '/apps/weazel-news',
+ })
+ expect(PHONE_APPS.find((app) => app.id === 'weazel-news')?.icon).toBe(
+ Newspaper,
+ )
expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({
category: 'utilities',
gridOrder: 26,
@@ -135,6 +147,7 @@ describe('app registry', () => {
expect(isPhoneAppId('skyride')).toBe(true)
expect(isPhoneAppId('music')).toBe(true)
expect(isPhoneAppId('companies')).toBe(true)
+ expect(isPhoneAppId('weazel-news')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
@@ -151,6 +164,7 @@ describe('app registry', () => {
(app) => app.id,
),
).toEqual([
+ 'weazel-news',
'picstagram',
'feather',
'fliptok',
diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts
index 4f49726..4052fd4 100644
--- a/frontend/src/config/apps.ts
+++ b/frontend/src/config/apps.ts
@@ -33,6 +33,7 @@ import {
ReceiptText,
UsersRound,
Building2,
+ Newspaper,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
@@ -72,6 +73,7 @@ import musicIcon from '@/assets/img/app-icons/music.svg'
import featherIcon from '@/assets/img/app-icons/feather.svg'
import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg'
import companiesIcon from '@/assets/img/app-icons/companies.svg'
+import weazelNewsIcon from '@/assets/img/app-icons/weazel-news.svg'
import type {
BuiltinPhoneAppDefinition,
BuiltinPhoneAppId,
@@ -83,6 +85,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS = shallowReactive([
+ {
+ category: 'social',
+ component: markRaw(
+ defineAsyncComponent(() => import('@/views/apps/weazel-news-app.vue')),
+ ),
+ dockOrder: null,
+ gridOrder: 29,
+ icon: markRaw(Newspaper),
+ iconClass: 'app-icon--weazel-news',
+ iconImage: weazelNewsIcon,
+ id: 'weazel-news',
+ labelKey: 'Apps.weazelNews.name',
+ route: '/apps/weazel-news',
+ },
{
category: 'utilities',
component: markRaw(
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 1827834..611035a 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -414,6 +414,136 @@ const defaultLocales: LocaleTree = {
request_failed: 'EasyShare is temporarily unavailable.',
},
},
+ weazelNews: {
+ name: 'Weazel News',
+ brand: 'WEAZEL NEWS',
+ navigation: 'Weazel News navigation',
+ tabs: {
+ home: 'Home',
+ categories: 'Categories',
+ search: 'Search',
+ editorial: 'Editorial',
+ },
+ back: 'Back',
+ retry: 'Try Again',
+ loadMore: 'Load More',
+ home: {
+ eyebrow: 'Los Santos, live',
+ latest: 'Latest News',
+ },
+ categories: {
+ title: 'Categories',
+ subtitle: 'Browse every story from the Weazel News desk.',
+ all: 'All Stories',
+ official: 'Official Notices',
+ events: 'Events & Leisure',
+ jobs: 'Jobs',
+ news: 'News & Events',
+ business: 'Business',
+ },
+ search: {
+ placeholder: 'Search articles...',
+ title: 'Search Weazel News',
+ emptyTitle: 'No Articles Found',
+ emptyBody: 'Try another headline, topic, or category.',
+ },
+ states: {
+ loading: 'Loading Weazel News...',
+ emptyTitle: 'No News Yet',
+ emptyBody:
+ 'The Weazel News desk has not published any articles here yet.',
+ errorTitle: 'Weazel News Is Unavailable',
+ readOnlyTitle: 'Read-only Access',
+ readOnlyBody:
+ 'Your current job can read Weazel News, but cannot manage articles.',
+ noManagedTitle: 'No Editorial Articles',
+ noManagedBody:
+ 'Create an article or change the current editorial filter.',
+ },
+ article: {
+ readMore: 'Read Article',
+ byline: 'By {author}',
+ updated: 'Updated {date}',
+ published: 'Published {date}',
+ coverAlt: 'Cover image for {title}',
+ },
+ editorial: {
+ title: 'Editorial Desk',
+ subtitle: 'Create, publish, and maintain Weazel News articles.',
+ newArticle: 'New Article',
+ published: 'Published',
+ drafts: 'Drafts',
+ all: 'All',
+ jobAccess: 'Signed in as {job}',
+ edit: 'Edit Article',
+ delete: 'Delete Article',
+ },
+ composer: {
+ newTitle: 'New Article',
+ editTitle: 'Edit Article',
+ title: 'Headline',
+ titlePlaceholder: 'Write a clear headline',
+ body: 'Article',
+ bodyPlaceholder: 'Write the full story...',
+ category: 'Category',
+ status: 'Publication Status',
+ cover: 'Cover Image',
+ chooseCover: 'Choose from Gallery',
+ changeCover: 'Change Cover',
+ removeCover: 'Remove Cover',
+ coverAlt: 'Selected article cover',
+ publish: 'Publish Article',
+ saveDraft: 'Save Draft',
+ saveChanges: 'Save Changes',
+ statusPublished: 'Published',
+ statusDraft: 'Draft',
+ },
+ delete: {
+ title: 'Delete Article?',
+ body: 'This article will be removed from Weazel News.',
+ cancel: 'Cancel',
+ confirm: 'Delete',
+ },
+ feedback: {
+ created: 'Article created.',
+ updated: 'Article updated.',
+ deleted: 'Article deleted.',
+ },
+ accessibility: {
+ articleList: 'Weazel News articles',
+ categoryList: 'News categories',
+ editorialList: 'Editorial articles',
+ openArticle: 'Open {title}',
+ openCategory: 'Open {category}',
+ openEditorial: 'Open the editorial desk',
+ newArticle: 'Create a new article',
+ editArticle: 'Edit {title}',
+ deleteArticle: 'Delete {title}',
+ search: 'Search Weazel News',
+ clearSearch: 'Clear article search',
+ coverPreview: 'Article cover preview',
+ removeCover: 'Remove the selected cover image',
+ status: 'Article status: {status}',
+ },
+ errors: {
+ feature_disabled: 'Weazel News is currently disabled.',
+ invalid_article: 'Enter a valid headline, article, and category.',
+ invalid_draft:
+ 'Enter a headline and article text before saving the draft.',
+ invalid_publish: 'Enter a headline and article text before publishing.',
+ invalid_request: 'Check the article details and try again.',
+ invalid_image: 'Choose a valid photo from this phone.',
+ invalid_attachment: 'Choose a valid photo from this phone.',
+ not_authorized: 'Your current job cannot manage Weazel News articles.',
+ article_not_found: 'This article is no longer available.',
+ not_found: 'This article is no longer available.',
+ revision_conflict:
+ 'This article changed on another device. Reload it and try again.',
+ rate_limited: 'Too many requests. Try again shortly.',
+ request_failed: 'Weazel News could not complete the request.',
+ default: 'Weazel News is temporarily unavailable.',
+ },
+ },
companies: companiesFallbackLocales,
crewlink: {
name: 'CrewLink',
diff --git a/frontend/src/stores/weazel-news.test.ts b/frontend/src/stores/weazel-news.test.ts
new file mode 100644
index 0000000..7eb50f6
--- /dev/null
+++ b/frontend/src/stores/weazel-news.test.ts
@@ -0,0 +1,212 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useWeazelNewsStore } from '@/stores/weazel-news'
+import type {
+ WeazelNewsArticle,
+ WeazelNewsArticleDraft,
+} from '@/types/weazel-news'
+import { nuiCall } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
+
+const mockNuiCall = vi.mocked(nuiCall)
+
+const article: WeazelNewsArticle = {
+ authorName: 'Jane Doe',
+ body: 'The full article body.',
+ category: 'news',
+ createdAt: 1_754_000_000,
+ excerpt: 'The article excerpt.',
+ id: '7d28b252-0532-4869-9512-e313e34d2fb8',
+ imageMediaId: 12,
+ imageUrl: 'https://media.invalid/weazel/article.webp',
+ publishedAt: 1_754_000_100,
+ revision: 4,
+ status: 'published',
+ title: 'Breaking news',
+ updatedAt: 1_754_000_100,
+}
+
+const draft: WeazelNewsArticleDraft = {
+ body: 'Updated body.',
+ category: 'business',
+ imageMediaId: null,
+ status: 'draft',
+ title: 'Updated headline',
+}
+
+describe('Weazel News store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockNuiCall.mockReset()
+ })
+
+ it('loads the server-owned management context and category counts', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ data: {
+ canManage: true,
+ categories: [
+ { count: 3, id: 'news' },
+ { count: 1, id: 'jobs' },
+ ],
+ jobGradeLabel: 'Editor',
+ jobLabel: 'Weazel News',
+ },
+ success: true,
+ })
+ const store = useWeazelNewsStore()
+
+ expect(await store.loadContext()).toBe(true)
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
+ expect(store.context?.canManage).toBe(true)
+ expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
+ })
+
+ it('loads and deduplicates appended public pages', async () => {
+ mockNuiCall
+ .mockResolvedValueOnce({
+ data: { hasMore: true, items: [article] },
+ success: true,
+ })
+ .mockResolvedValueOnce({
+ data: {
+ hasMore: false,
+ items: [
+ { ...article, revision: 5, title: 'Canonical headline' },
+ { ...article, id: 'second-article' },
+ ],
+ },
+ success: true,
+ })
+ const store = useWeazelNewsStore()
+
+ await store.loadPublic({ category: 'news', search: 'breaking' })
+ await store.loadPublic({
+ append: true,
+ category: 'news',
+ search: 'breaking',
+ })
+
+ expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'weazel-news:list', {
+ category: 'news',
+ offset: 0,
+ search: 'breaking',
+ })
+ expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'weazel-news:list', {
+ category: 'news',
+ offset: 1,
+ search: 'breaking',
+ })
+ expect(store.publicItems).toHaveLength(2)
+ expect(store.publicItems[0]?.title).toBe('Canonical headline')
+ expect(store.publicHasMore).toBe(false)
+ })
+
+ it('loads paged management results including drafts', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
+ success: true,
+ })
+ const store = useWeazelNewsStore()
+
+ expect(
+ await store.loadManaged('draft', { offset: 20, search: 'city' }),
+ ).toBe(true)
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:manage-list', {
+ offset: 20,
+ search: 'city',
+ status: 'draft',
+ })
+ expect(store.managedItems[0]?.status).toBe('draft')
+ })
+
+ it('loads a canonical article for public or management detail', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ data: { article },
+ success: true,
+ })
+ const store = useWeazelNewsStore()
+
+ expect(await store.loadArticle(article.id, true)).toBe(true)
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:get', {
+ id: article.id,
+ manage: true,
+ })
+ expect(store.selected).toEqual(article)
+ })
+
+ it('normalizes a direct canonical create response', async () => {
+ const created = { ...article, id: 'created-article', revision: 1 }
+ mockNuiCall.mockResolvedValueOnce({ data: created, success: true })
+ const store = useWeazelNewsStore()
+
+ expect(await store.create({ ...draft, status: 'published' })).toEqual(
+ created,
+ )
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:create', {
+ ...draft,
+ status: 'published',
+ })
+ expect(store.managedItems).toEqual([created])
+ expect(store.publicItems).toEqual([created])
+ })
+
+ it('sends the optimistic revision and applies the canonical update', async () => {
+ const canonical = {
+ ...article,
+ ...draft,
+ excerpt: 'Updated excerpt.',
+ revision: 5,
+ updatedAt: 1_754_100_000,
+ }
+ mockNuiCall.mockResolvedValueOnce({
+ data: { article: canonical },
+ success: true,
+ })
+ const store = useWeazelNewsStore()
+ store.publicItems = [article]
+ store.managedItems = [article]
+ store.selected = article
+
+ expect(await store.update(article, draft)).toEqual(canonical)
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:update', {
+ ...draft,
+ id: article.id,
+ revision: 4,
+ })
+ expect(store.selected).toEqual(canonical)
+ expect(store.managedItems).toEqual([canonical])
+ expect(store.publicItems).toEqual([])
+ })
+
+ it('keeps local articles unchanged after a revision conflict', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ error: 'revision_conflict',
+ success: false,
+ })
+ const store = useWeazelNewsStore()
+ store.managedItems = [article]
+
+ expect(await store.update(article, draft)).toBeNull()
+ expect(store.managedItems).toEqual([article])
+ expect(store.error).toBe('revision_conflict')
+ })
+
+ it('deletes by id and revision only after server confirmation', async () => {
+ mockNuiCall.mockResolvedValueOnce({ success: true })
+ const store = useWeazelNewsStore()
+ store.publicItems = [article]
+ store.managedItems = [article]
+ store.selected = article
+
+ expect(await store.remove(article)).toBe(true)
+ expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:delete', {
+ id: article.id,
+ revision: article.revision,
+ })
+ expect(store.publicItems).toEqual([])
+ expect(store.managedItems).toEqual([])
+ expect(store.selected).toBeNull()
+ })
+})
diff --git a/frontend/src/stores/weazel-news.ts b/frontend/src/stores/weazel-news.ts
new file mode 100644
index 0000000..303a8d9
--- /dev/null
+++ b/frontend/src/stores/weazel-news.ts
@@ -0,0 +1,283 @@
+import { defineStore } from 'pinia'
+
+import type {
+ WeazelNewsArticle,
+ WeazelNewsArticleDraft,
+ WeazelNewsArticleResponse,
+ WeazelNewsArticleSummary,
+ WeazelNewsContext,
+ WeazelNewsListResponse,
+ WeazelNewsManagedListOptions,
+ WeazelNewsManageStatus,
+ WeazelNewsPublicListOptions,
+} from '@/types/weazel-news'
+import { nuiCall } from '@/utils/nui'
+
+type ArticleMutationResponse = WeazelNewsArticle | WeazelNewsArticleResponse
+
+function readArticle(
+ data: ArticleMutationResponse | undefined,
+): WeazelNewsArticle | null {
+ if (!data) return null
+ return 'article' in data ? data.article : data
+}
+
+function replaceArticle(
+ items: WeazelNewsArticleSummary[],
+ article: WeazelNewsArticleSummary,
+): WeazelNewsArticleSummary[] {
+ return items.map((item) => (item.id === article.id ? article : item))
+}
+
+function mergeArticles(
+ current: WeazelNewsArticleSummary[],
+ incoming: WeazelNewsArticleSummary[],
+): WeazelNewsArticleSummary[] {
+ const merged = new Map(current.map((article) => [article.id, article]))
+ for (const article of incoming) merged.set(article.id, article)
+ return [...merged.values()]
+}
+
+export const useWeazelNewsStore = defineStore('weazel-news', {
+ state: () => ({
+ context: null as WeazelNewsContext | null,
+ contextError: '',
+ contextLoadSequence: 0,
+ contextLoading: false,
+ detailError: '',
+ detailLoadSequence: 0,
+ detailLoading: false,
+ error: '',
+ loading: false,
+ managedError: '',
+ managedLoadSequence: 0,
+ managedLoading: false,
+ managedHasMore: false,
+ managedItems: [] as WeazelNewsArticleSummary[],
+ mutating: false,
+ publicHasMore: false,
+ publicError: '',
+ publicLoadSequence: 0,
+ publicLoading: false,
+ publicItems: [] as WeazelNewsArticleSummary[],
+ selected: null as WeazelNewsArticle | null,
+ }),
+ actions: {
+ async loadContext(): Promise {
+ const requestSequence = ++this.contextLoadSequence
+ this.contextLoading = true
+ this.loading = true
+ const response = await nuiCall('weazel-news:context')
+ if (requestSequence !== this.contextLoadSequence) return false
+ this.contextLoading = false
+ this.loading =
+ this.publicLoading || this.managedLoading || this.detailLoading
+ if (!response.success || !response.data) {
+ this.contextError = response.error ?? 'request_failed'
+ this.error = this.contextError
+ return false
+ }
+ this.context = response.data
+ this.contextError = ''
+ this.error = ''
+ return true
+ },
+ async loadPublic(
+ options: WeazelNewsPublicListOptions = {},
+ ): Promise {
+ const append = options.append ?? false
+ const requestSequence = ++this.publicLoadSequence
+ this.publicLoading = true
+ this.loading = true
+ const response = await nuiCall(
+ 'weazel-news:list',
+ {
+ category: options.category ?? null,
+ offset: options.offset ?? (append ? this.publicItems.length : 0),
+ search: options.search ?? '',
+ },
+ )
+ if (requestSequence !== this.publicLoadSequence) return false
+ this.publicLoading = false
+ this.loading =
+ this.contextLoading || this.managedLoading || this.detailLoading
+ if (!response.success || !response.data) {
+ this.publicError = response.error ?? 'request_failed'
+ this.error = this.publicError
+ return false
+ }
+ this.publicItems = append
+ ? mergeArticles(this.publicItems, response.data.items)
+ : response.data.items
+ this.publicHasMore = response.data.hasMore
+ this.publicError = ''
+ this.error = ''
+ return true
+ },
+ async loadManaged(
+ status: WeazelNewsManageStatus,
+ options: WeazelNewsManagedListOptions = {},
+ ): Promise {
+ const append = options.append ?? false
+ const requestSequence = ++this.managedLoadSequence
+ this.managedLoading = true
+ this.loading = true
+ const response = await nuiCall(
+ 'weazel-news:manage-list',
+ {
+ offset: options.offset ?? (append ? this.managedItems.length : 0),
+ search: options.search ?? '',
+ status,
+ },
+ )
+ if (requestSequence !== this.managedLoadSequence) return false
+ this.managedLoading = false
+ this.loading =
+ this.contextLoading || this.publicLoading || this.detailLoading
+ if (!response.success || !response.data) {
+ this.managedError = response.error ?? 'request_failed'
+ this.error = this.managedError
+ return false
+ }
+ this.managedItems = append
+ ? mergeArticles(this.managedItems, response.data.items)
+ : response.data.items
+ this.managedHasMore = response.data.hasMore
+ this.managedError = ''
+ this.error = ''
+ return true
+ },
+ async loadArticle(id: string, manage = false): Promise {
+ const requestSequence = ++this.detailLoadSequence
+ this.detailLoading = true
+ this.loading = true
+ const response = await nuiCall(
+ 'weazel-news:get',
+ {
+ id,
+ manage,
+ },
+ )
+ if (requestSequence !== this.detailLoadSequence) return false
+ this.detailLoading = false
+ this.loading =
+ this.contextLoading || this.publicLoading || this.managedLoading
+ const article = response.success ? readArticle(response.data) : null
+ if (!article) {
+ this.detailError = response.error ?? 'request_failed'
+ this.error = this.detailError
+ return false
+ }
+ this.selected = article
+ this.detailError = ''
+ this.publicItems = replaceArticle(this.publicItems, article)
+ this.managedItems = replaceArticle(this.managedItems, article)
+ this.error = ''
+ return true
+ },
+ async create(
+ draft: WeazelNewsArticleDraft,
+ ): Promise {
+ this.publicLoadSequence += 1
+ this.managedLoadSequence += 1
+ this.detailLoadSequence += 1
+ this.publicLoading = false
+ this.managedLoading = false
+ this.detailLoading = false
+ this.loading = this.contextLoading
+ this.mutating = true
+ const response = await nuiCall(
+ 'weazel-news:create',
+ draft,
+ )
+ this.mutating = false
+ const article = response.success ? readArticle(response.data) : null
+ if (!article) {
+ this.error = response.error ?? 'request_failed'
+ return null
+ }
+ this.selected = article
+ this.managedItems = [
+ article,
+ ...this.managedItems.filter((item) => item.id !== article.id),
+ ]
+ if (article.status === 'published') {
+ this.publicItems = [
+ article,
+ ...this.publicItems.filter((item) => item.id !== article.id),
+ ]
+ }
+ this.error = ''
+ return article
+ },
+ async update(
+ article: WeazelNewsArticle,
+ draft: WeazelNewsArticleDraft,
+ ): Promise {
+ this.publicLoadSequence += 1
+ this.managedLoadSequence += 1
+ this.detailLoadSequence += 1
+ this.publicLoading = false
+ this.managedLoading = false
+ this.detailLoading = false
+ this.loading = this.contextLoading
+ this.mutating = true
+ const response = await nuiCall(
+ 'weazel-news:update',
+ {
+ ...draft,
+ id: article.id,
+ revision: article.revision,
+ },
+ )
+ this.mutating = false
+ const canonical = response.success ? readArticle(response.data) : null
+ if (!canonical) {
+ this.error = response.error ?? 'request_failed'
+ return null
+ }
+ if (this.selected?.id === canonical.id) this.selected = canonical
+ this.managedItems = replaceArticle(this.managedItems, canonical)
+ this.publicItems =
+ canonical.status === 'published'
+ ? [
+ canonical,
+ ...this.publicItems.filter((item) => item.id !== canonical.id),
+ ].sort(
+ (left, right) =>
+ (right.publishedAt ?? 0) - (left.publishedAt ?? 0),
+ )
+ : this.publicItems.filter((item) => item.id !== canonical.id)
+ this.error = ''
+ return canonical
+ },
+ async remove(article: WeazelNewsArticle): Promise {
+ this.publicLoadSequence += 1
+ this.managedLoadSequence += 1
+ this.detailLoadSequence += 1
+ this.publicLoading = false
+ this.managedLoading = false
+ this.detailLoading = false
+ this.loading = this.contextLoading
+ this.mutating = true
+ const response = await nuiCall('weazel-news:delete', {
+ id: article.id,
+ revision: article.revision,
+ })
+ this.mutating = false
+ if (!response.success) {
+ this.error = response.error ?? 'request_failed'
+ return false
+ }
+ this.publicItems = this.publicItems.filter(
+ (item) => item.id !== article.id,
+ )
+ this.managedItems = this.managedItems.filter(
+ (item) => item.id !== article.id,
+ )
+ if (this.selected?.id === article.id) this.selected = null
+ this.error = ''
+ return true
+ },
+ },
+})
diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts
index a21a28d..27dfb16 100644
--- a/frontend/src/types/apps.ts
+++ b/frontend/src/types/apps.ts
@@ -37,6 +37,7 @@ export type BuiltinPhoneAppId =
| 'feather'
| 'crewlink'
| 'companies'
+ | 'weazel-news'
declare const externalPhoneAppId: unique symbol
diff --git a/frontend/src/types/weazel-news.ts b/frontend/src/types/weazel-news.ts
new file mode 100644
index 0000000..6784bfb
--- /dev/null
+++ b/frontend/src/types/weazel-news.ts
@@ -0,0 +1,76 @@
+export const WEAZEL_NEWS_CATEGORY_IDS = [
+ 'official',
+ 'events',
+ 'jobs',
+ 'news',
+ 'business',
+] as const
+
+export const WEAZEL_NEWS_ARTICLE_STATUSES = ['draft', 'published'] as const
+
+export type WeazelNewsCategoryId = (typeof WEAZEL_NEWS_CATEGORY_IDS)[number]
+
+export type WeazelNewsArticleStatus =
+ (typeof WEAZEL_NEWS_ARTICLE_STATUSES)[number]
+
+export type WeazelNewsManageStatus = 'all' | WeazelNewsArticleStatus
+
+export type WeazelNewsCategorySummary = {
+ count: number
+ id: WeazelNewsCategoryId
+}
+
+export type WeazelNewsArticle = {
+ authorName: string
+ body: string
+ category: WeazelNewsCategoryId
+ createdAt: number
+ excerpt: string
+ id: string
+ imageMediaId?: number | null
+ imageUrl?: string | null
+ publishedAt?: number | null
+ revision: number
+ status: WeazelNewsArticleStatus
+ title: string
+ updatedAt: number
+}
+
+export type WeazelNewsArticleSummary = Omit
+
+export type WeazelNewsArticleDraft = {
+ body: string
+ category: WeazelNewsCategoryId
+ imageMediaId: number | null
+ status: WeazelNewsArticleStatus
+ title: string
+}
+
+export type WeazelNewsContext = {
+ canManage: boolean
+ categories: WeazelNewsCategorySummary[]
+ jobGradeLabel?: string
+ jobLabel?: string
+}
+
+export type WeazelNewsListResponse = {
+ hasMore: boolean
+ items: WeazelNewsArticleSummary[]
+}
+
+export type WeazelNewsPublicListOptions = {
+ append?: boolean
+ category?: WeazelNewsCategoryId | null
+ offset?: number
+ search?: string
+}
+
+export type WeazelNewsManagedListOptions = {
+ append?: boolean
+ offset?: number
+ search?: string
+}
+
+export type WeazelNewsArticleResponse = {
+ article: WeazelNewsArticle
+}
diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts
index c586705..605af42 100644
--- a/frontend/src/utils/preferences.ts
+++ b/frontend/src/utils/preferences.ts
@@ -87,6 +87,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'neon-drop': { enabled: true, sounds: true },
citymarkt: { enabled: true, sounds: true },
companies: { enabled: true, sounds: true },
+ 'weazel-news': { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
picstagram: { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
diff --git a/frontend/src/views/apps/weazel-news-app.vue b/frontend/src/views/apps/weazel-news-app.vue
new file mode 100644
index 0000000..db706fe
--- /dev/null
+++ b/frontend/src/views/apps/weazel-news-app.vue
@@ -0,0 +1,2308 @@
+
+
+
+
+
+
+
+
+
+ {{ t('brand') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('tabs.home') }}
+
+
+
+ {{ t('tabs.categories') }}
+
+
+
+ {{ t('tabs.search') }}
+
+
+
+ {{ t('tabs.editorial') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ W
+
+
+
{{
+ categoryLabel(selectedArticle.category)
+ }}
+
{{ selectedArticle.title }}
+
+
{{
+ selectedArticle.authorName.charAt(0).toUpperCase()
+ }}
+
+ {{
+ t('article.byline', { author: selectedArticle.authorName })
+ }}
+ {{
+ t('article.published', {
+ date: formatDate(
+ selectedArticle.publishedAt || selectedArticle.updatedAt,
+ ),
+ })
+ }}
+
+
+
{{ selectedArticle.body }}
+
+
+
+ {{ t('editorial.edit') }}
+
+
+ {{ t('editorial.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('delete.title') }}
+ {{ t('delete.body') }}
+
+
+ {{ t('delete.cancel') }}
+
+
+ {{ t('delete.confirm') }}
+
+
+
+
+
+ {{ toastText }}
+
+
+
+
+
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index f2e8742..e4b0a3f 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -2336,6 +2336,194 @@ let mockMedia = [
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
},
]
+
+const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
+let weazelNewsSequence = 8
+let weazelNewsArticles = [
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000001',
+ title: 'Port Authority announces temporary harbor restrictions',
+ body: 'The Port Authority has announced temporary navigation restrictions around the southern harbor while maintenance crews inspect the main shipping channel. Commercial operators should follow marked diversion routes and expect short delays through the afternoon. Emergency traffic will continue without interruption.',
+ excerpt:
+ 'Temporary navigation restrictions are in effect around the southern harbor while crews inspect the main shipping channel.',
+ category: 'official',
+ imageUrl: 'https://picsum.photos/seed/weazel-harbor/1200/760',
+ imageMediaId: null,
+ authorName: 'Avery Brooks',
+ createdAt: Date.now() - 35 * 60 * 1000,
+ updatedAt: Date.now() - 28 * 60 * 1000,
+ publishedAt: Date.now() - 30 * 60 * 1000,
+ status: 'published',
+ revision: 2,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000002',
+ title: 'Vinewood summer festival opens this weekend',
+ body: "Vinewood Boulevard will welcome food stands, live performers, and classic cars during this weekend's summer festival. Organizers recommend using public parking near the eastern entrance and arriving early for the evening concert. The event is free and runs from noon until late.",
+ excerpt:
+ 'Food stands, live performers, and classic cars are coming to Vinewood Boulevard this weekend.',
+ category: 'events',
+ imageUrl: 'https://picsum.photos/seed/weazel-vinewood/1200/760',
+ imageMediaId: null,
+ authorName: 'Maya Chen',
+ createdAt: Date.now() - 2 * 60 * 60 * 1000,
+ updatedAt: Date.now() - 2 * 60 * 60 * 1000,
+ publishedAt: Date.now() - 2 * 60 * 60 * 1000,
+ status: 'published',
+ revision: 1,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000003',
+ title: 'City services expand recruitment drive',
+ body: 'Several city departments have opened a coordinated recruitment drive for new staff. Positions are available across emergency response, transport, and public administration. Applicants should review individual department requirements before attending the recruitment office at City Hall.',
+ excerpt:
+ 'City departments are recruiting new staff across emergency response, transport, and public administration.',
+ category: 'jobs',
+ imageUrl: null,
+ imageMediaId: null,
+ authorName: 'Jordan Hayes',
+ createdAt: Date.now() - 4 * 60 * 60 * 1000,
+ updatedAt: Date.now() - 3 * 60 * 60 * 1000,
+ publishedAt: Date.now() - 3 * 60 * 60 * 1000,
+ status: 'published',
+ revision: 2,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000004',
+ title: 'Traffic returns to normal after Del Perro closure',
+ body: 'Traffic is moving normally again through Del Perro after crews cleared an earlier road obstruction. Police have reopened every lane and removed the temporary diversion signs. Drivers may still encounter brief congestion while the remaining queue disperses.',
+ excerpt:
+ 'Every lane through Del Perro has reopened after crews cleared an earlier road obstruction.',
+ category: 'news',
+ imageUrl: 'https://picsum.photos/seed/weazel-del-perro/1200/760',
+ imageMediaId: null,
+ authorName: 'Avery Brooks',
+ createdAt: Date.now() - 7 * 60 * 60 * 1000,
+ updatedAt: Date.now() - 6 * 60 * 60 * 1000,
+ publishedAt: Date.now() - 6 * 60 * 60 * 1000,
+ status: 'published',
+ revision: 3,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000005',
+ title: 'Downtown retailers report strong evening trade',
+ body: 'Independent retailers across downtown Los Santos reported stronger evening trade following the launch of extended opening hours. Business owners credited increased foot traffic and a busy restaurant district. The trial will continue through the end of the month before a permanent schedule is considered.',
+ excerpt:
+ 'Independent downtown retailers are seeing stronger evening trade during a trial of extended opening hours.',
+ category: 'business',
+ imageUrl: 'https://picsum.photos/seed/weazel-downtown/1200/760',
+ imageMediaId: null,
+ authorName: 'Maya Chen',
+ createdAt: Date.now() - 26 * 60 * 60 * 1000,
+ updatedAt: Date.now() - 25 * 60 * 60 * 1000,
+ publishedAt: Date.now() - 25 * 60 * 60 * 1000,
+ status: 'published',
+ revision: 2,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000006',
+ title: 'Interview: preparing for the next racing season',
+ body: 'Local racing teams are preparing new vehicles and reviewing safety procedures before the next sanctioned season begins. Weazel News spoke with organizers about the revised technical checks, route planning, and what spectators can expect at the opening round.',
+ excerpt:
+ 'Local racing teams are preparing vehicles and reviewing safety procedures for the next sanctioned season.',
+ category: 'events',
+ imageUrl: 'https://picsum.photos/seed/sky-phone-3/800/600',
+ imageMediaId: 3,
+ authorName: 'Jordan Hayes',
+ createdAt: Date.now() - 55 * 60 * 1000,
+ updatedAt: Date.now() - 12 * 60 * 1000,
+ publishedAt: null,
+ status: 'draft',
+ revision: 4,
+ },
+ {
+ id: '34c0ec54-bfb1-4ad7-81da-000000000007',
+ title: 'Draft briefing for Monday morning',
+ body: 'The editorial desk is collecting confirmed service notices and transport updates for Monday morning. This working draft will be expanded when the final statements arrive from the relevant city departments.',
+ excerpt:
+ 'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.',
+ category: 'official',
+ imageUrl: null,
+ imageMediaId: null,
+ authorName: 'Jordan Hayes',
+ createdAt: Date.now() - 18 * 60 * 1000,
+ updatedAt: Date.now() - 8 * 60 * 1000,
+ publishedAt: null,
+ status: 'draft',
+ revision: 2,
+ },
+]
+
+function weazelNewsExcerpt(body) {
+ const normalized = body.replace(/\s+/g, ' ').trim()
+ return normalized.length <= 240
+ ? normalized
+ : `${normalized.slice(0, 237).trimEnd()}...`
+}
+
+function weazelNewsImageUrl(imageMediaId) {
+ if (imageMediaId === null) return null
+ const media = mockMedia.find(
+ (item) => item.id === imageMediaId && item.mediaType === 'photo',
+ )
+ return media?.url ?? null
+}
+
+function validateWeazelNewsDraft(data) {
+ const title = typeof data.title === 'string' ? data.title.trim() : ''
+ const body = typeof data.body === 'string' ? data.body.trim() : ''
+ const status = data.status
+ const minimumTitleLength = 1
+ const minimumBodyLength = 1
+ if (
+ Array.from(title).length < minimumTitleLength ||
+ Array.from(title).length > 160 ||
+ Array.from(body).length < minimumBodyLength ||
+ Array.from(body).length > 12000 ||
+ !weazelNewsCategoryIds.includes(data.category) ||
+ !['draft', 'published'].includes(status)
+ ) {
+ return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' }
+ }
+
+ let imageMediaId = null
+ if (data.imageMediaId !== null && data.imageMediaId !== undefined) {
+ imageMediaId = Number(data.imageMediaId)
+ if (
+ !Number.isSafeInteger(imageMediaId) ||
+ !weazelNewsImageUrl(imageMediaId)
+ ) {
+ return { error: 'invalid_attachment' }
+ }
+ }
+
+ return {
+ article: {
+ body,
+ category: data.category,
+ excerpt: weazelNewsExcerpt(body),
+ imageMediaId,
+ imageUrl: weazelNewsImageUrl(imageMediaId),
+ status,
+ title,
+ },
+ }
+}
+
+function pageWeazelNewsArticles(items, data) {
+ const offset = Math.max(0, Math.floor(Number(data.offset) || 0))
+ const requestedLimit = Math.floor(Number(data.limit) || 20)
+ const limit = Math.min(50, Math.max(1, requestedLimit))
+ return {
+ hasMore: offset + limit < items.length,
+ items: items.slice(offset, offset + limit).map((article) => {
+ const summary = { ...article }
+ delete summary.body
+ return summary
+ }),
+ }
+}
+
const marketplaceInquiries = [
{
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
@@ -4111,6 +4299,177 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
+ const canManageWeazelNews = testScenario !== 'weazel-readonly'
+ if (endpoint === 'weazel-news:context') {
+ response.json({
+ success: true,
+ data: {
+ canManage: canManageWeazelNews,
+ categories: weazelNewsCategoryIds.map((id) => ({
+ count: weazelNewsArticles.filter(
+ (article) =>
+ article.status === 'published' && article.category === id,
+ ).length,
+ id,
+ })),
+ ...(canManageWeazelNews
+ ? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' }
+ : {}),
+ },
+ })
+ return
+ }
+ if (endpoint === 'weazel-news:list') {
+ const category = request.body.category ?? null
+ const search = String(request.body.search ?? '')
+ .trim()
+ .toLowerCase()
+ if (
+ category !== null &&
+ !weazelNewsCategoryIds.includes(String(category))
+ ) {
+ response.json({ success: false, error: 'invalid_request' })
+ return
+ }
+ const items = weazelNewsArticles
+ .filter((article) => article.status === 'published')
+ .filter((article) => category === null || article.category === category)
+ .filter(
+ (article) =>
+ !search ||
+ `${article.title} ${article.body}`.toLowerCase().includes(search),
+ )
+ .sort((left, right) => (right.publishedAt ?? 0) - (left.publishedAt ?? 0))
+ response.json({
+ success: true,
+ data: pageWeazelNewsArticles(items, request.body),
+ })
+ return
+ }
+ if (endpoint === 'weazel-news:get') {
+ if (request.body.manage === true && !canManageWeazelNews) {
+ response.json({ success: false, error: 'not_authorized' })
+ return
+ }
+ const article = weazelNewsArticles.find(
+ (item) =>
+ item.id === request.body.id &&
+ (request.body.manage === true || item.status === 'published'),
+ )
+ response.json(
+ article
+ ? { success: true, data: { article } }
+ : { success: false, error: 'not_found' },
+ )
+ return
+ }
+ if (endpoint === 'weazel-news:manage-list') {
+ if (!canManageWeazelNews) {
+ response.json({ success: false, error: 'not_authorized' })
+ return
+ }
+ const status = String(request.body.status ?? 'all')
+ const search = String(request.body.search ?? '')
+ .trim()
+ .toLowerCase()
+ if (!['all', 'published', 'draft'].includes(status)) {
+ response.json({ success: false, error: 'invalid_request' })
+ return
+ }
+ const items = weazelNewsArticles
+ .filter((article) => status === 'all' || article.status === status)
+ .filter(
+ (article) =>
+ !search ||
+ `${article.title} ${article.body}`.toLowerCase().includes(search),
+ )
+ .sort((left, right) => right.updatedAt - left.updatedAt)
+ response.json({
+ success: true,
+ data: pageWeazelNewsArticles(items, request.body),
+ })
+ return
+ }
+ if (
+ ['weazel-news:create', 'weazel-news:update', 'weazel-news:delete'].includes(
+ endpoint,
+ ) &&
+ !canManageWeazelNews
+ ) {
+ response.json({ success: false, error: 'not_authorized' })
+ return
+ }
+ if (endpoint === 'weazel-news:create') {
+ const validation = validateWeazelNewsDraft(request.body)
+ if (!validation.article) {
+ response.json({ success: false, error: validation.error })
+ return
+ }
+ const now = Date.now()
+ const id = `34c0ec54-bfb1-4ad7-81da-${String(weazelNewsSequence).padStart(12, '0')}`
+ weazelNewsSequence += 1
+ const article = {
+ ...validation.article,
+ authorName: 'Jordan Hayes',
+ createdAt: now,
+ id,
+ publishedAt: validation.article.status === 'published' ? now : null,
+ revision: 1,
+ updatedAt: now,
+ }
+ weazelNewsArticles.unshift(article)
+ response.json({ success: true, data: { article } })
+ return
+ }
+ if (endpoint === 'weazel-news:update') {
+ const index = weazelNewsArticles.findIndex(
+ (article) => article.id === request.body.id,
+ )
+ if (index < 0) {
+ response.json({ success: false, error: 'not_found' })
+ return
+ }
+ const current = weazelNewsArticles[index]
+ if (current.revision !== Number(request.body.revision)) {
+ response.json({ success: false, error: 'revision_conflict' })
+ return
+ }
+ const validation = validateWeazelNewsDraft(request.body)
+ if (!validation.article) {
+ response.json({ success: false, error: validation.error })
+ return
+ }
+ const now = Date.now()
+ const article = {
+ ...current,
+ ...validation.article,
+ publishedAt:
+ validation.article.status === 'published'
+ ? (current.publishedAt ?? now)
+ : null,
+ revision: current.revision + 1,
+ updatedAt: now,
+ }
+ weazelNewsArticles[index] = article
+ response.json({ success: true, data: { article } })
+ return
+ }
+ if (endpoint === 'weazel-news:delete') {
+ const index = weazelNewsArticles.findIndex(
+ (article) => article.id === request.body.id,
+ )
+ if (index < 0) {
+ response.json({ success: false, error: 'not_found' })
+ return
+ }
+ if (weazelNewsArticles[index].revision !== Number(request.body.revision)) {
+ response.json({ success: false, error: 'revision_conflict' })
+ return
+ }
+ weazelNewsArticles.splice(index, 1)
+ response.json({ success: true })
+ return
+ }
if (endpoint.startsWith('companies:') && testScenario === 'companies-error') {
response.json({ success: false, error: 'service_unavailable' })
return
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 347a95d..9db7931 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -138,6 +138,131 @@ Locales["en"] = {
unavailableBody = "This custom app could not be loaded. Check that its resource is started, then reopen the app.",
close = "Close app",
},
+ weazelNews = {
+ name = "Weazel News",
+ brand = "WEAZEL NEWS",
+ navigation = "Weazel News navigation",
+ tabs = {
+ home = "Home",
+ categories = "Categories",
+ search = "Search",
+ editorial = "Editorial",
+ },
+ back = "Back",
+ retry = "Try Again",
+ loadMore = "Load More",
+ home = {
+ eyebrow = "Los Santos, live",
+ latest = "Latest News",
+ },
+ categories = {
+ title = "Categories",
+ subtitle = "Browse every story from the Weazel News desk.",
+ all = "All Stories",
+ official = "Official Notices",
+ events = "Events & Leisure",
+ jobs = "Jobs",
+ news = "News & Events",
+ business = "Business",
+ },
+ search = {
+ placeholder = "Search articles...",
+ title = "Search Weazel News",
+ emptyTitle = "No Articles Found",
+ emptyBody = "Try another headline, topic, or category.",
+ },
+ states = {
+ loading = "Loading Weazel News...",
+ emptyTitle = "No News Yet",
+ emptyBody = "The Weazel News desk has not published any articles here yet.",
+ errorTitle = "Weazel News Is Unavailable",
+ readOnlyTitle = "Read-only Access",
+ readOnlyBody = "Your current job can read Weazel News, but cannot manage articles.",
+ noManagedTitle = "No Editorial Articles",
+ noManagedBody = "Create an article or change the current editorial filter.",
+ },
+ article = {
+ readMore = "Read Article",
+ byline = "By {author}",
+ updated = "Updated {date}",
+ published = "Published {date}",
+ coverAlt = "Cover image for {title}",
+ },
+ editorial = {
+ title = "Editorial Desk",
+ subtitle = "Create, publish, and maintain Weazel News articles.",
+ newArticle = "New Article",
+ published = "Published",
+ drafts = "Drafts",
+ all = "All",
+ jobAccess = "Signed in as {job}",
+ edit = "Edit Article",
+ delete = "Delete Article",
+ },
+ composer = {
+ newTitle = "New Article",
+ editTitle = "Edit Article",
+ title = "Headline",
+ titlePlaceholder = "Write a clear headline",
+ body = "Article",
+ bodyPlaceholder = "Write the full story...",
+ category = "Category",
+ status = "Publication Status",
+ cover = "Cover Image",
+ chooseCover = "Choose from Gallery",
+ changeCover = "Change Cover",
+ removeCover = "Remove Cover",
+ coverAlt = "Selected article cover",
+ publish = "Publish Article",
+ saveDraft = "Save Draft",
+ saveChanges = "Save Changes",
+ statusPublished = "Published",
+ statusDraft = "Draft",
+ },
+ delete = {
+ title = "Delete Article?",
+ body = "This article will be removed from Weazel News.",
+ cancel = "Cancel",
+ confirm = "Delete",
+ },
+ feedback = {
+ created = "Article created.",
+ updated = "Article updated.",
+ deleted = "Article deleted.",
+ },
+ accessibility = {
+ articleList = "Weazel News articles",
+ categoryList = "News categories",
+ editorialList = "Editorial articles",
+ openArticle = "Open {title}",
+ openCategory = "Open {category}",
+ openEditorial = "Open the editorial desk",
+ newArticle = "Create a new article",
+ editArticle = "Edit {title}",
+ deleteArticle = "Delete {title}",
+ search = "Search Weazel News",
+ clearSearch = "Clear article search",
+ coverPreview = "Article cover preview",
+ removeCover = "Remove the selected cover image",
+ status = "Article status: {status}",
+ },
+ errors = {
+ feature_disabled = "Weazel News is currently disabled.",
+ invalid_article = "Enter a valid headline, article, and category.",
+ invalid_draft = "Enter a headline and article text before saving the draft.",
+ invalid_publish = "Enter a headline and article text before publishing.",
+ invalid_request = "Check the article details and try again.",
+ invalid_image = "Choose a valid photo from this phone.",
+ invalid_attachment = "Choose a valid photo from this phone.",
+ not_authorized = "Your current job cannot manage Weazel News articles.",
+ article_not_found = "This article is no longer available.",
+ not_found = "This article is no longer available.",
+ revision_conflict = "This article changed on another device. Reload it and try again.",
+ rate_limited = "Too many requests. Try again shortly.",
+ request_failed = "Weazel News could not complete the request.",
+ default = "Weazel News is temporarily unavailable.",
+ },
+ },
crewlink = {
name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network",
signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings",
diff --git a/sky_phone/config/weazel_news.lua b/sky_phone/config/weazel_news.lua
new file mode 100644
index 0000000..af0ecc0
--- /dev/null
+++ b/sky_phone/config/weazel_news.lua
@@ -0,0 +1,28 @@
+Config.WeazelNews = {
+ Enabled = true,
+ PageSize = 20,
+ MaximumOffset = 10000,
+ SearchMaxLength = 80,
+ DraftTitleMinLength = 1,
+ DraftBodyMinLength = 1,
+ TitleMinLength = 1,
+ TitleMaxLength = 160,
+ BodyMinLength = 1,
+ BodyMaxLength = 12000,
+ ExcerptMaxLength = 240,
+ RateLimits = {
+ Read = 120,
+ Write = 20,
+ },
+ Categories = {
+ "official",
+ "events",
+ "jobs",
+ "news",
+ "business",
+ },
+ -- Framework job name = minimum grade. Every listed job can manage every article.
+ AllowedJobs = {
+ weazel = 0,
+ },
+}
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index e5f67e7..76b7f45 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -51,6 +51,7 @@ server_scripts {
'config/companies.lua',
'config/media.lua',
'config/music.lua',
+ 'config/weazel_news.lua',
'config/locales/*.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
@@ -71,6 +72,7 @@ server_scripts {
'source/server/payphones.lua',
'source/server/calls.lua',
'source/server/media.lua',
+ 'source/server/weazel_news.lua',
'source/server/messages.lua',
'source/server/easyshare.lua',
'source/server/darkchat.lua',
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 2e88d41..8f8d109 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -76,6 +76,13 @@ local server_callbacks = {
"pages:share-citymarkt",
"pages:react",
"pages:delete",
+ "weazel-news:context",
+ "weazel-news:list",
+ "weazel-news:get",
+ "weazel-news:manage-list",
+ "weazel-news:create",
+ "weazel-news:update",
+ "weazel-news:delete",
"fliptok:register",
"fliptok:login",
"fliptok:logout",
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 6dfbf02..d15efff 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -2499,6 +2499,38 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+ {
+ name = "sky_phone_weazel_articles",
+ columns = {
+ { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "title", type = "VARCHAR(160) NOT NULL" },
+ { name = "body", type = "LONGTEXT NOT NULL" },
+ { name = "excerpt", type = "VARCHAR(240) NOT NULL" },
+ { name = "category", type = "ENUM('official','events','jobs','news','business') NOT NULL" },
+ { name = "image_media_id", type = "BIGINT UNSIGNED NULL" },
+ { name = "author_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "author_name", type = "VARCHAR(120) NOT NULL" },
+ { name = "updated_by_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "status", type = "ENUM('draft','published') NOT NULL DEFAULT 'draft'" },
+ { name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
+ { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
+ { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
+ { name = "published_at", type = "DATETIME NULL" },
+ { name = "deleted_at", type = "DATETIME NULL" },
+ { name = "deleted_by_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
+ },
+ primaryKey = "id",
+ indexes = {
+ { name = "idx_sky_phone_weazel_public", columns = "(`status`, `deleted_at`, `published_at`, `id`)" },
+ { name = "idx_sky_phone_weazel_category", columns = "(`category`, `status`, `deleted_at`, `published_at`, `id`)" },
+ { name = "idx_sky_phone_weazel_manage", columns = "(`deleted_at`, `status`, `updated_at`, `id`)" },
+ { name = "idx_sky_phone_weazel_media", columns = "(`image_media_id`)" },
+ },
+ foreignKeys = {
+ { column = "image_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
+ },
+ tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+ },
}
Bridge.Database.Migrate("sky_phone", schema)
diff --git a/sky_phone/source/server/weazel_news.lua b/sky_phone/source/server/weazel_news.lua
new file mode 100644
index 0000000..a70f797
--- /dev/null
+++ b/sky_phone/source/server/weazel_news.lua
@@ -0,0 +1,597 @@
+Bridge.Database.AfterMigration("sky_phone", function()
+local config = Config.WeazelNews
+
+if type(config) ~= "table" then
+ error("[sky_phone] Config.WeazelNews must be configured.")
+end
+
+local function require_integer_config(name, minimum, maximum)
+ local value = config[name]
+ if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then
+ error(("[sky_phone] Config.WeazelNews.%s must be an integer between %d and %d."):format(name, minimum, maximum))
+ end
+end
+
+if type(config.Enabled) ~= "boolean" then
+ error("[sky_phone] Config.WeazelNews.Enabled must be true or false.")
+end
+require_integer_config("PageSize", 1, 100)
+require_integer_config("MaximumOffset", 0, 1000000)
+require_integer_config("SearchMaxLength", 1, 256)
+require_integer_config("DraftTitleMinLength", 1, 160)
+require_integer_config("DraftBodyMinLength", 1, 12000)
+require_integer_config("TitleMinLength", 1, 160)
+require_integer_config("TitleMaxLength", 1, 160)
+require_integer_config("BodyMinLength", 1, 12000)
+require_integer_config("BodyMaxLength", 1, 12000)
+require_integer_config("ExcerptMaxLength", 1, 240)
+if config.DraftTitleMinLength > config.TitleMaxLength
+ or config.DraftBodyMinLength > config.BodyMaxLength
+ or config.TitleMinLength > config.TitleMaxLength
+ or config.BodyMinLength > config.BodyMaxLength
+then
+ error("[sky_phone] Weazel News minimum text lengths cannot exceed their maximums.")
+end
+if type(config.RateLimits) ~= "table" then
+ error("[sky_phone] Config.WeazelNews.RateLimits must be a table.")
+end
+for _, name in ipairs({ "Read", "Write" }) do
+ local value = config.RateLimits[name]
+ if type(value) ~= "number" or value ~= math.floor(value) or value < 1 or value > 10000 then
+ error(("[sky_phone] Config.WeazelNews.RateLimits.%s must be an integer between 1 and 10000."):format(name))
+ end
+end
+
+local supported_categories = {
+ official = true,
+ events = true,
+ jobs = true,
+ news = true,
+ business = true,
+}
+if type(config.Categories) ~= "table" then
+ error("[sky_phone] Config.WeazelNews.Categories must be a table.")
+end
+
+local categories = {}
+for _, category in ipairs(config.Categories or {}) do
+ if type(category) ~= "string" or #category < 1 or #category > 32
+ or not category:match("^[%l_]+$") or categories[category]
+ then
+ error(("[sky_phone] Invalid Weazel News category '%s'."):format(tostring(category)))
+ end
+ categories[category] = true
+end
+for category in pairs(supported_categories) do
+ if not categories[category] then
+ error(("[sky_phone] Config.WeazelNews.Categories is missing supported category '%s'."):format(category))
+ end
+end
+for category in pairs(categories) do
+ if not supported_categories[category] then
+ error(("[sky_phone] Config.WeazelNews.Categories contains unsupported category '%s'."):format(category))
+ end
+end
+
+if type(config.AllowedJobs) ~= "table" then
+ error("[sky_phone] Config.WeazelNews.AllowedJobs must be a table.")
+end
+
+for job_name, minimum_grade in pairs(config.AllowedJobs or {}) do
+ if type(job_name) ~= "string" or #job_name < 1 or #job_name > 64
+ or not job_name:match("^[%w_-]+$")
+ or type(minimum_grade) ~= "number" or minimum_grade < 0
+ or minimum_grade ~= math.floor(minimum_grade)
+ then
+ error(("[sky_phone] Invalid Weazel News AllowedJobs entry '%s'."):format(tostring(job_name)))
+ end
+end
+
+local article_summary_columns = [[
+ article.`id`, article.`title`, article.`excerpt`, article.`category`,
+ article.`image_media_id`, media.`url` AS `image_url`, article.`author_name`,
+ article.`status`, article.`revision`,
+ UNIX_TIMESTAMP(article.`created_at`) AS `created_at_unix`,
+ UNIX_TIMESTAMP(article.`updated_at`) AS `updated_at_unix`,
+ UNIX_TIMESTAMP(article.`published_at`) AS `published_at_unix`
+]]
+local article_detail_columns = article_summary_columns .. ", article.`body`"
+
+local function trim(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+ return value:match("^%s*(.-)%s*$")
+end
+
+local function text_length(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+ local success, length = pcall(utf8.len, value)
+ return success and length or nil
+end
+
+local function truncate_text(value, maximum)
+ local length = text_length(value)
+ if not length or length <= maximum then
+ return value
+ end
+ local next_character = utf8.offset(value, maximum + 1)
+ return next_character and value:sub(1, next_character - 1) or value
+end
+
+local function make_excerpt(body)
+ return truncate_text(body:gsub("%s+", " "), config.ExcerptMaxLength)
+end
+
+local function valid_integer(value, minimum, maximum)
+ local number = type(value) == "number" and value or nil
+ if not number or number ~= math.floor(number) or number < minimum or number > maximum then
+ return nil
+ end
+ return number
+end
+
+local function valid_uuid(value)
+ return type(value) == "string"
+ and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
+end
+
+local function affected_rows(result)
+ if type(result) == "number" then
+ return result
+ end
+ return type(result) == "table" and tonumber(result.affectedRows) or 0
+end
+
+local function require_phone(source, operation, maximum)
+ if not config.Enabled then
+ return nil, { success = false, error = "feature_disabled" }
+ end
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return nil, error_response
+ end
+ if not SkyPhone.AllowOperation(source, "weazel_" .. operation, maximum, 60) then
+ return nil, { success = false, error = "rate_limited" }
+ end
+ return session
+end
+
+local function management_access(source)
+ local job = Bridge.Framework.GetJob(source)
+ local minimum_grade = (config.AllowedJobs or {})[job.name]
+ local grade = tonumber(job.grade) or 0
+ if minimum_grade == nil or grade < minimum_grade then
+ return nil
+ end
+ return {
+ job_label = type(job.label) == "string" and job.label or "",
+ grade_label = type(job.gradeLabel) == "string" and job.gradeLabel or "",
+ }
+end
+
+local function require_manager(source)
+ local access = management_access(source)
+ if not access then
+ return nil, { success = false, error = "not_authorized" }
+ end
+ return access
+end
+
+local function actor_identity(source)
+ local identifier = Bridge.Framework.GetIdentifier(source)
+ if type(identifier) ~= "string" or #identifier < 1 or #identifier > 80 then
+ Bridge.Debug("error", "[sky_phone] Could not resolve a valid Weazel News actor for source %s.", tostring(source))
+ return nil
+ end
+
+ local first_name = trim(Bridge.Framework.GetFirstname(source)) or ""
+ local last_name = trim(Bridge.Framework.GetLastname(source)) or ""
+ local name = trim((first_name .. " " .. last_name))
+ if not name or name == "" then
+ name = trim(GetPlayerName(source)) or "Weazel News"
+ end
+ if not text_length(name) then
+ name = "Weazel News"
+ end
+ return {
+ identifier = identifier,
+ name = truncate_text(name, 120),
+ }
+end
+
+local function article_dto(row)
+ if not row then
+ return nil
+ end
+ local created_at = tonumber(row.created_at_unix)
+ local updated_at = tonumber(row.updated_at_unix)
+ if not created_at or not updated_at then
+ error(("[sky_phone] Weazel News article '%s' has invalid timestamps."):format(tostring(row.id)))
+ end
+ return {
+ id = row.id,
+ title = row.title,
+ body = row.body,
+ excerpt = row.excerpt,
+ category = row.category,
+ imageUrl = row.image_url,
+ imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil,
+ authorName = row.author_name,
+ createdAt = created_at * 1000,
+ updatedAt = updated_at * 1000,
+ publishedAt = row.published_at_unix and tonumber(row.published_at_unix) * 1000 or nil,
+ status = row.status,
+ revision = tonumber(row.revision) or 1,
+ }
+end
+
+local function load_article(id, include_drafts)
+ local visibility = include_drafts and "" or " AND article.`status` = 'published'"
+ local rows = Bridge.Database.Query(([[
+ SELECT %s
+ FROM `sky_phone_weazel_articles` article
+ LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id`
+ WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s
+ LIMIT 1
+ ]]):format(article_detail_columns, visibility), { id })
+ return article_dto(rows[1])
+end
+
+local function query_articles(where_clause, parameters, order_clause, offset)
+ local query_parameters = {}
+ for _, value in ipairs(parameters) do
+ query_parameters[#query_parameters + 1] = value
+ end
+ query_parameters[#query_parameters + 1] = config.PageSize + 1
+ query_parameters[#query_parameters + 1] = offset
+
+ local rows = Bridge.Database.Query(([[
+ SELECT %s
+ FROM `sky_phone_weazel_articles` article
+ LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id`
+ WHERE %s
+ ORDER BY %s
+ LIMIT ? OFFSET ?
+ ]]):format(article_summary_columns, where_clause, order_clause), query_parameters)
+ local has_more = #rows > config.PageSize
+ if has_more then
+ rows[#rows] = nil
+ end
+ local articles = {}
+ for _, row in ipairs(rows) do
+ articles[#articles + 1] = article_dto(row)
+ end
+ return articles, has_more
+end
+
+local function validate_article(source, data, retained_media_id)
+ if type(data) ~= "table" then
+ return nil, "invalid_article"
+ end
+ local title = trim(data.title)
+ local body = trim(data.body)
+ local title_length = text_length(title)
+ local body_length = text_length(body)
+ local status = data.status
+ local minimum_title_length = status == "draft" and config.DraftTitleMinLength or config.TitleMinLength
+ local minimum_body_length = status == "draft" and config.DraftBodyMinLength or config.BodyMinLength
+ if not title_length or title:find("%z") or title_length < minimum_title_length or title_length > config.TitleMaxLength
+ or not body_length or body:find("%z")
+ or body_length < minimum_body_length or body_length > config.BodyMaxLength
+ or not categories[data.category]
+ or (status ~= "draft" and status ~= "published")
+ then
+ return nil, status == "draft" and "invalid_draft" or "invalid_publish"
+ end
+
+ local media_id
+ if data.imageMediaId ~= nil then
+ media_id = valid_integer(data.imageMediaId, 1, 9007199254740991)
+ if not media_id then
+ return nil, "invalid_attachment"
+ end
+ if media_id ~= retained_media_id then
+ local url = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
+ if not url then
+ return nil, "invalid_attachment"
+ end
+ end
+ end
+ return {
+ title = title,
+ body = body,
+ excerpt = make_excerpt(body),
+ category = data.category,
+ image_media_id = media_id,
+ status = status,
+ }
+end
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source)
+ local _, error_response = require_phone(source, "read", config.RateLimits.Read)
+ if error_response then
+ return error_response
+ end
+ local access = management_access(source)
+ local counts = Bridge.Database.Query([[
+ SELECT `category`, COUNT(*) AS `count`
+ FROM `sky_phone_weazel_articles`
+ WHERE `status` = 'published' AND `deleted_at` IS NULL
+ GROUP BY `category`
+ ]], {})
+ local counts_by_category = {}
+ for _, row in ipairs(counts) do
+ counts_by_category[row.category] = tonumber(row.count) or 0
+ end
+ local category_context = {}
+ for _, category in ipairs(config.Categories) do
+ category_context[#category_context + 1] = {
+ id = category,
+ count = counts_by_category[category] or 0,
+ }
+ end
+ return {
+ success = true,
+ data = {
+ canManage = access ~= nil,
+ jobLabel = access and access.job_label or nil,
+ jobGradeLabel = access and access.grade_label or nil,
+ categories = category_context,
+ },
+ }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:list", function(source, data)
+ local _, error_response = require_phone(source, "read", config.RateLimits.Read)
+ if error_response then
+ return error_response
+ end
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
+ local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset)
+ local search = trim(data.search or "")
+ local search_length = text_length(search)
+ if not offset or not search_length or search_length > config.SearchMaxLength
+ or (data.category ~= nil and not categories[data.category])
+ then
+ return { success = false, error = "invalid_request" }
+ end
+
+ local where = { "article.`status` = 'published'", "article.`deleted_at` IS NULL" }
+ local parameters = {}
+ if data.category then
+ where[#where + 1] = "article.`category` = ?"
+ parameters[#parameters + 1] = data.category
+ end
+ if search ~= "" then
+ where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))"
+ parameters[#parameters + 1] = search
+ parameters[#parameters + 1] = search
+ end
+ local items, has_more = query_articles(
+ table.concat(where, " AND "),
+ parameters,
+ "article.`published_at` DESC, article.`id` DESC",
+ offset
+ )
+ return { success = true, data = { items = items, hasMore = has_more } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:get", function(source, data)
+ local _, error_response = require_phone(source, "read", config.RateLimits.Read)
+ if error_response then
+ return error_response
+ end
+ if type(data) ~= "table" or not valid_uuid(data.id) then
+ return { success = false, error = "invalid_request" }
+ end
+ local include_drafts = data.manage == true
+ if include_drafts then
+ local _, manager_error = require_manager(source)
+ if manager_error then
+ return manager_error
+ end
+ end
+ local article = load_article(data.id, include_drafts)
+ if not article then
+ return { success = false, error = "not_found" }
+ end
+ return { success = true, data = { article = article } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:manage-list", function(source, data)
+ local _, error_response = require_phone(source, "read", config.RateLimits.Read)
+ if error_response then
+ return error_response
+ end
+ local _, manager_error = require_manager(source)
+ if manager_error then
+ return manager_error
+ end
+ if type(data) ~= "table" then
+ return { success = false, error = "invalid_request" }
+ end
+ local status = data.status or "all"
+ local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset)
+ local search = trim(data.search or "")
+ local search_length = text_length(search)
+ if (status ~= "all" and status ~= "published" and status ~= "draft") or not offset
+ or not search_length or search_length > config.SearchMaxLength
+ then
+ return { success = false, error = "invalid_request" }
+ end
+ local where = { "article.`deleted_at` IS NULL" }
+ local parameters = {}
+ if status ~= "all" then
+ where[#where + 1] = "article.`status` = ?"
+ parameters[#parameters + 1] = status
+ end
+ if search ~= "" then
+ where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))"
+ parameters[#parameters + 1] = search
+ parameters[#parameters + 1] = search
+ end
+ local items, has_more = query_articles(
+ table.concat(where, " AND "),
+ parameters,
+ "article.`updated_at` DESC, article.`id` DESC",
+ offset
+ )
+ return { success = true, data = { items = items, hasMore = has_more } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data)
+ local _, error_response = require_phone(source, "write", config.RateLimits.Write)
+ if error_response then
+ return error_response
+ end
+ local _, manager_error = require_manager(source)
+ if manager_error then
+ return manager_error
+ end
+ local article, validation_error = validate_article(source, data)
+ if not article then
+ return { success = false, error = validation_error }
+ end
+ local actor = actor_identity(source)
+ if not actor then
+ return { success = false, error = "request_failed" }
+ end
+ local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
+ local id = ids[1] and ids[1].id
+ if not valid_uuid(id) then
+ error("[sky_phone] Database did not generate a Weazel News article id.")
+ end
+ Bridge.Database.Query([[
+ INSERT INTO `sky_phone_weazel_articles`
+ (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`,
+ `author_name`, `updated_by_identifier`, `status`, `published_at`)
+ VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL))
+ ]], {
+ id,
+ article.title,
+ article.body,
+ article.excerpt,
+ article.category,
+ article.image_media_id or 0,
+ actor.identifier,
+ actor.name,
+ actor.identifier,
+ article.status,
+ article.status,
+ })
+ local created = load_article(id, true)
+ if not created then
+ error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id))
+ end
+ return { success = true, data = { article = created } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
+ local _, error_response = require_phone(source, "write", config.RateLimits.Write)
+ if error_response then
+ return error_response
+ end
+ local _, manager_error = require_manager(source)
+ if manager_error then
+ return manager_error
+ end
+ local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
+ if not revision or not valid_uuid(data.id) then
+ return { success = false, error = "invalid_request" }
+ end
+ local current_rows = Bridge.Database.Query([[
+ SELECT `image_media_id`, `revision`
+ FROM `sky_phone_weazel_articles`
+ WHERE `id` = ? AND `deleted_at` IS NULL
+ LIMIT 1
+ ]], { data.id })
+ local current = current_rows[1]
+ if not current then
+ return { success = false, error = "not_found" }
+ end
+ if tonumber(current.revision) ~= revision then
+ return { success = false, error = "revision_conflict" }
+ end
+ local retained_media_id = current.image_media_id and tonumber(current.image_media_id) or nil
+ local article, validation_error = validate_article(source, data, retained_media_id)
+ if not article then
+ return { success = false, error = validation_error }
+ end
+ local actor = actor_identity(source)
+ if not actor then
+ return { success = false, error = "request_failed" }
+ end
+ local result = Bridge.Database.Query([[
+ UPDATE `sky_phone_weazel_articles`
+ SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0),
+ `published_at` = CASE
+ WHEN ? = 'draft' THEN NULL
+ WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP
+ ELSE `published_at`
+ END,
+ `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1
+ WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
+ ]], {
+ article.title,
+ article.body,
+ article.excerpt,
+ article.category,
+ article.image_media_id or 0,
+ article.status,
+ article.status,
+ actor.identifier,
+ data.id,
+ revision,
+ })
+ if affected_rows(result) ~= 1 then
+ return { success = false, error = "revision_conflict" }
+ end
+ local updated = load_article(data.id, true)
+ if not updated then
+ error(("[sky_phone] Could not reload updated Weazel News article '%s'."):format(data.id))
+ end
+ return { success = true, data = { article = updated } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:weazel-news:delete", function(source, data)
+ local _, error_response = require_phone(source, "write", config.RateLimits.Write)
+ if error_response then
+ return error_response
+ end
+ local _, manager_error = require_manager(source)
+ if manager_error then
+ return manager_error
+ end
+ local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
+ if not revision or not valid_uuid(data.id) then
+ return { success = false, error = "invalid_request" }
+ end
+ local actor = actor_identity(source)
+ if not actor then
+ return { success = false, error = "request_failed" }
+ end
+ local result = Bridge.Database.Query([[
+ UPDATE `sky_phone_weazel_articles`
+ SET `deleted_at` = CURRENT_TIMESTAMP, `deleted_by_identifier` = ?, `revision` = `revision` + 1
+ WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
+ ]], { actor.identifier, data.id, revision })
+ if affected_rows(result) ~= 1 then
+ local rows = Bridge.Database.Query([[
+ SELECT `revision`, `deleted_at`
+ FROM `sky_phone_weazel_articles`
+ WHERE `id` = ?
+ LIMIT 1
+ ]], { data.id })
+ if not rows[1] or rows[1].deleted_at then
+ return { success = false, error = "not_found" }
+ end
+ return { success = false, error = "revision_conflict" }
+ end
+ return { success = true }
+end)
+end)
diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql
index 9605fa6..a269887 100644
--- a/sky_phone/sql/install.sql
+++ b/sky_phone/sql/install.sql
@@ -1138,3 +1138,28 @@ CREATE TABLE IF NOT EXISTS `sky_phone_company_audit` (
KEY `idx_sky_phone_company_audit` (`company_id`,`created_at`,`id`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` (
+ `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `title` VARCHAR(160) NOT NULL,
+ `body` LONGTEXT NOT NULL,
+ `excerpt` VARCHAR(240) NOT NULL,
+ `category` ENUM('official','events','jobs','news','business') NOT NULL,
+ `image_media_id` BIGINT UNSIGNED NULL,
+ `author_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `author_name` VARCHAR(120) NOT NULL,
+ `updated_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `status` ENUM('draft','published') NOT NULL DEFAULT 'draft',
+ `revision` INT UNSIGNED NOT NULL DEFAULT 1,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `published_at` DATETIME NULL,
+ `deleted_at` DATETIME NULL,
+ `deleted_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_sky_phone_weazel_public` (`status`,`deleted_at`,`published_at`,`id`),
+ KEY `idx_sky_phone_weazel_category` (`category`,`status`,`deleted_at`,`published_at`,`id`),
+ KEY `idx_sky_phone_weazel_manage` (`deleted_at`,`status`,`updated_at`,`id`),
+ KEY `idx_sky_phone_weazel_media` (`image_media_id`),
+ FOREIGN KEY (`image_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
From c7ca474e4f51ab178cd7ef4d0a5e47675bb5f7c6 Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt"
Date: Thu, 13 Aug 2026 01:55:07 +0200
Subject: [PATCH 45/63] ADD - added Extern Image/Video Import
---
README.md | 15 +-
frontend/src/stores/phone.ts | 32 +
frontend/src/types/media.ts | 38 +
frontend/src/utils/media.test.ts | 12 +
frontend/src/utils/media.ts | 26 +
frontend/src/views/apps/GalleryApp.vue | 252 +++++-
frontend/testserver/index.cjs | 119 +++
sky_phone/config/locales/en.lua | 16 +
sky_phone/config/media.lua | 44 +-
sky_phone/fxmanifest.lua | 3 +
sky_phone/source/client/main.lua | 13 +
sky_phone/source/server/db_migrate.lua | 20 +
sky_phone/source/server/media.lua | 74 +-
sky_phone/source/server/media_import.lua | 725 ++++++++++++++++++
.../source/server/media_import/fivemanage.lua | 237 ++++++
.../source/server/media_import/manifest.lua | 152 ++++
sky_phone/sql/install.sql | 5 +
17 files changed, 1754 insertions(+), 29 deletions(-)
create mode 100644 sky_phone/source/server/media_import.lua
create mode 100644 sky_phone/source/server/media_import/fivemanage.lua
create mode 100644 sky_phone/source/server/media_import/manifest.lua
diff --git a/README.md b/README.md
index 923633b..1ee12c2 100644
--- a/README.md
+++ b/README.md
@@ -154,8 +154,13 @@ and restart the resource after updating the configuration.
- `oxmysql` with MySQL/MariaDB.
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
- A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set the
- server-only `Config.Media.FiveManage.ApiKey` in `sky_phone/config/media.lua`; the token is never
- sent to NUI because clients receive temporary presigned upload URLs instead.
+ server-only value in `sky_phone/config/media.lua`:
+
+ ```lua
+ Config.Media.FiveManage.ApiKey = "replace-with-your-media-token"
+ ```
+
+ The token is never sent to NUI because clients receive temporary presigned upload URLs instead.
- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order.
## Messages GIF provider
@@ -176,7 +181,11 @@ Database migrations run automatically. Existing `sky_phone_mail_accounts` instal
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees
them. Signing out hides cloud media without deleting it. Factory reset removes device-local media
-and attempts to delete its remote FiveManage files, while account-owned media remains in the cloud.
+and attempts to delete only Phone-created remote FiveManage files. Media imported from a website is
+removed locally but never deleted at its source. Register import websites under
+`Config.Media.Import.Websites` in `sky_phone/config/media.lua`; built-in adapters support FiveManage
+files and version-1 JSON manifests. The Gallery import form accepts direct HTTPS image/video links
+only when their hostname matches the selected website's `AllowedMediaHosts`.
For a fresh manual database installation, import `sky_phone/sql/install.sql`. It contains the complete current table, key, index, collation, and foreign-key schema. Runtime migrations remain authoritative for upgrading an existing installation and must stay enabled.
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 5751a8e..964747c 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -3106,13 +3106,45 @@ const defaultLocales: LocaleTree = {
zoomOut: 'Zoom out',
resetZoom: 'Reset zoom',
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
+ import: {
+ action: 'Import',
+ title: 'Import',
+ chooseSource: 'Choose a website',
+ linkTitle: 'Import from Link',
+ linkBody:
+ 'Paste a direct link to an image or video from this website.',
+ linkLabel: 'Image or video link',
+ linkPlaceholder: 'https://...',
+ linkCompleted: 'Media imported.',
+ loading: 'Loading media...',
+ emptyTitle: 'No Media',
+ emptyBody: 'This website has no media of this type.',
+ loadMore: 'Load More',
+ alreadyImported: 'Imported',
+ failed: 'Failed',
+ completed: 'Imported {imported} media.',
+ partial: 'Imported {imported} of {total} media.',
+ },
errors: {
cancelled: 'The media action was cancelled.',
capture_failed: 'Unable to capture the game view.',
+ invalid_import_request: 'The import request is invalid.',
+ invalid_import_media: 'This media item cannot be imported.',
invalid_media_type: 'The media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Gallery uploads are not configured.',
+ import_media_not_allowed: 'This media item is not allowed.',
+ import_media_too_large: 'This media item is too large.',
+ import_media_unavailable: 'This media item is no longer available.',
+ import_provider_failed: 'The website could not load its media.',
+ import_provider_unauthorized: 'The website credentials are invalid.',
+ import_source_not_found: 'This website is not registered.',
+ import_source_unavailable: 'This website is temporarily unavailable.',
+ invalid_import_url: 'Enter a valid HTTPS media link.',
+ import_url_not_allowed: 'This link is not from the selected website.',
+ import_url_unavailable: 'The linked media could not be reached.',
+ import_size_unavailable: 'The website did not provide the media size.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts
index 03587b0..e39ae62 100644
--- a/frontend/src/types/media.ts
+++ b/frontend/src/types/media.ts
@@ -8,6 +8,44 @@ export type PhoneMedia = {
url: string
}
+export type MediaImportSource = {
+ id: string
+ label: string
+ mediaTypes: MediaType[]
+}
+
+export type MediaImportSources = {
+ maxSelection: number
+ sources: MediaImportSource[]
+}
+
+export type ExternalMedia = {
+ externalId: string
+ filename: string
+ imported: boolean
+ mediaType: MediaType
+ size: number
+ sourceId: string
+ url: string
+}
+
+export type MediaImportPage = {
+ hasMore: boolean
+ items: ExternalMedia[]
+ page: number
+ total: number
+}
+
+export type MediaImportFailure = {
+ error: string
+ externalId: string
+}
+
+export type MediaImportResult = {
+ failed: MediaImportFailure[]
+ imported: PhoneMedia[]
+}
+
export type UploadReady = {
captureToken: string
correlationId: string
diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts
index d444aec..c2fd8de 100644
--- a/frontend/src/utils/media.test.ts
+++ b/frontend/src/utils/media.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
filterMedia,
+ formatMediaSize,
formatRecordingDuration,
hasNextMediaPage,
mediaErrorKey,
@@ -44,8 +45,19 @@ describe('media utilities', () => {
expect(formatRecordingDuration(3_725_000)).toBe('62:05')
})
+ it('formats imported media sizes with the active locale', () => {
+ expect(formatMediaSize(512, 'en')).toBe('512 B')
+ expect(formatMediaSize(1_572_864, 'en')).toBe('1.5 MB')
+ })
+
it('maps unknown server failures to the localized default', () => {
expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout')
+ expect(mediaErrorKey('import_media_too_large')).toBe(
+ 'import_media_too_large',
+ )
+ expect(mediaErrorKey('import_url_not_allowed')).toBe(
+ 'import_url_not_allowed',
+ )
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts
index 1bc21e3..d38bca8 100644
--- a/frontend/src/utils/media.ts
+++ b/frontend/src/utils/media.ts
@@ -37,14 +37,40 @@ export function formatRecordingDuration(elapsedMs: number): string {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+export function formatMediaSize(bytes: number, locale: string): string {
+ const safeBytes = Math.max(0, bytes)
+ if (safeBytes < 1024) return `${safeBytes} B`
+ const units = ['KB', 'MB', 'GB']
+ let value = safeBytes / 1024
+ let unit = units[0]
+ for (let index = 1; index < units.length && value >= 1024; index += 1) {
+ value /= 1024
+ unit = units[index]
+ }
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${unit}`
+}
+
export function mediaErrorKey(error?: string): string {
const known = new Set([
'cancelled',
'capture_failed',
'invalid_media_type',
+ 'invalid_import_request',
+ 'invalid_import_media',
+ 'invalid_import_url',
'invalid_upload',
'invalid_upload_token',
'missing_config',
+ 'import_media_not_allowed',
+ 'import_media_too_large',
+ 'import_media_unavailable',
+ 'import_provider_failed',
+ 'import_provider_unauthorized',
+ 'import_source_not_found',
+ 'import_source_unavailable',
+ 'import_url_not_allowed',
+ 'import_url_unavailable',
+ 'import_size_unavailable',
'not_found',
'operation_in_progress',
'owner_changed',
diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue
index c23a1e3..be78d2d 100644
--- a/frontend/src/views/apps/GalleryApp.vue
+++ b/frontend/src/views/apps/GalleryApp.vue
@@ -4,6 +4,9 @@ import {
kButton,
kDialog,
kLink,
+ kList,
+ kListInput,
+ kListItem,
kNavbar,
kNavbarBackLink,
kPage,
@@ -12,13 +15,28 @@ import {
kSegmentedButton,
kToast,
} from 'konsta/vue'
-import { Play, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
+import {
+ ChevronRight,
+ Globe2,
+ Link2,
+ Play,
+ RotateCcw,
+ Trash2,
+ ZoomIn,
+ ZoomOut,
+} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
-import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
+import type {
+ DeleteResult,
+ GalleryFilter,
+ MediaImportSource,
+ MediaImportSources,
+ PhoneMedia,
+} from '@/types/media'
import {
hasNextMediaPage,
MEDIA_PAGE_SIZE,
@@ -58,6 +76,12 @@ const fetching = ref(false)
const hasMore = ref(true)
const loadError = ref('')
const selected = ref(null)
+const importMode = ref<'form' | 'gallery' | 'sources'>('gallery')
+const importSources = ref([])
+const importSource = ref(null)
+const importUrl = ref('')
+const importError = ref('')
+const importing = ref(false)
const deleteDialogOpened = ref(false)
const cancelButtonColors = {
fillBgIos: 'bg-[#8e8e93] active:bg-[#7a7a7f]',
@@ -143,6 +167,71 @@ function showToast(text: string): void {
}, 3000)
}
+async function loadImportSources(): Promise {
+ const response = await nuiCall('media:import:sources')
+ if (!response.success || !response.data) return
+ importSources.value = response.data.sources
+}
+
+function selectImportSource(source: MediaImportSource): void {
+ importSource.value = source
+ importMode.value = 'form'
+ importUrl.value = ''
+ importError.value = ''
+}
+
+function openImport(): void {
+ if (importSources.value.length === 1) {
+ selectImportSource(importSources.value[0])
+ return
+ }
+ importMode.value = 'sources'
+}
+
+function closeImport(): void {
+ importMode.value = 'gallery'
+ importSource.value = null
+ importUrl.value = ''
+ importError.value = ''
+}
+
+function backFromImportForm(): void {
+ if (importSources.value.length > 1) {
+ importMode.value = 'sources'
+ importSource.value = null
+ importUrl.value = ''
+ importError.value = ''
+ return
+ }
+ closeImport()
+}
+
+function updateImportUrl(event: Event): void {
+ importUrl.value = (event.target as HTMLInputElement).value
+ importError.value = ''
+}
+
+async function commitUrlImport(): Promise {
+ const url = importUrl.value.trim()
+ if (!importSource.value || !url || importing.value) return
+ importing.value = true
+ importError.value = ''
+ const response = await nuiCall('media:import:url', {
+ sourceId: importSource.value.id,
+ url,
+ })
+ importing.value = false
+ if (!response.success || !response.data) {
+ importError.value = phone.t(
+ `Apps.photos.errors.${mediaErrorKey(response.error)}`,
+ )
+ return
+ }
+ media.value = mergeMedia(media.value, [response.data])
+ closeImport()
+ showToast(phone.t('Apps.photos.import.linkCompleted'))
+}
+
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
dateStyle: 'medium',
@@ -358,6 +447,7 @@ watch(hasMore, () => void nextTick().then(observeMore))
onMounted(() => {
window.addEventListener('message', onMessage)
void loadGallery()
+ void loadImportSources()
})
onBeforeUnmount(() => {
@@ -371,12 +461,105 @@ onBeforeUnmount(() => {
+
+
+
+
+
+
+ {{ phone.t('Apps.photos.import.chooseSource') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
{
+
+
+
+ {{ phone.t('Apps.photos.import.action') }}
+
+
+
@@ -621,6 +815,54 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow: hidden;
}
+.gallery-import-page {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.gallery-import-intro {
+ margin-bottom: 0;
+ color: #8e8e93;
+}
+.gallery-import-form {
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 48px 16px 24px;
+ text-align: center;
+}
+.gallery-import-form-icon {
+ width: 72px;
+ height: 72px;
+ display: grid;
+ place-items: center;
+ border-radius: 22px;
+ background: #0a84ff;
+ color: #fff;
+ box-shadow: 0 10px 28px #0a84ff4d;
+}
+.gallery-import-form h2 {
+ margin: 20px 0 7px;
+ font-size: 21px;
+ font-weight: 700;
+}
+.gallery-import-form p {
+ max-width: 280px;
+ margin: 0;
+ color: #8e8e93;
+ font-size: 13px;
+ line-height: 1.45;
+}
+.gallery-import-url-list {
+ width: 100%;
+ margin-top: 26px;
+}
+.gallery-import-submit {
+ width: calc(100% - 32px);
+ margin-top: 14px;
+}
.gallery-content {
min-height: 0;
flex: 1;
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index f99c22a..debea44 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -2184,6 +2184,52 @@ let mockMedia = [
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
},
]
+const mockImportSources = [
+ {
+ id: 'media_archive',
+ label: 'Media Archive',
+ mediaTypes: ['photo', 'video'],
+ },
+ { id: 'event_cdn', label: 'Event CDN', mediaTypes: ['photo'] },
+]
+const mockImportMedia = [
+ {
+ externalId: 'archive-photo-1',
+ filename: 'Vespucci Sunset.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 2_481_152,
+ sourceId: 'media_archive',
+ url: 'https://picsum.photos/seed/sky-import-1/900/1200',
+ },
+ {
+ externalId: 'archive-photo-2',
+ filename: 'Downtown Meet.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 3_114_205,
+ sourceId: 'media_archive',
+ url: 'https://picsum.photos/seed/sky-import-2/1200/900',
+ },
+ {
+ externalId: 'archive-video-1',
+ filename: 'Flower Clip.mp4',
+ imported: false,
+ mediaType: 'video',
+ size: 8_241_152,
+ sourceId: 'media_archive',
+ url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
+ },
+ {
+ externalId: 'event-photo-1',
+ filename: 'Opening Night.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 1_824_331,
+ sourceId: 'event_cdn',
+ url: 'https://picsum.photos/seed/sky-event-1/900/1200',
+ },
+]
const marketplaceInquiries = [
{
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
@@ -6698,6 +6744,79 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: media })
return
}
+ if (endpoint === 'media:import:sources') {
+ response.json({
+ success: true,
+ data: { maxSelection: 10, sources: mockImportSources },
+ })
+ return
+ }
+ if (endpoint === 'media:import:list') {
+ const page = Math.max(1, Number(request.body.page) || 1)
+ const limit = 30
+ const filtered = mockImportMedia.filter(
+ (item) =>
+ item.sourceId === request.body.sourceId &&
+ item.mediaType === request.body.mediaType,
+ )
+ const offset = (page - 1) * limit
+ response.json({
+ success: true,
+ data: {
+ hasMore: offset + limit < filtered.length,
+ items: filtered.slice(offset, offset + limit),
+ page,
+ total: filtered.length,
+ },
+ })
+ return
+ }
+ if (endpoint === 'media:import:commit') {
+ const externalIds = Array.isArray(request.body.externalIds)
+ ? request.body.externalIds
+ : []
+ const imported = []
+ const failed = []
+ for (const externalId of externalIds) {
+ const item = mockImportMedia.find(
+ (candidate) =>
+ candidate.externalId === externalId &&
+ candidate.sourceId === request.body.sourceId,
+ )
+ if (!item) {
+ failed.push({ error: 'import_media_unavailable', externalId })
+ continue
+ }
+ item.imported = true
+ const media = {
+ createdAt: Date.now(),
+ id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
+ mediaType: item.mediaType,
+ url: item.url,
+ }
+ mockMedia.unshift(media)
+ imported.push(media)
+ }
+ response.json({ success: true, data: { failed, imported } })
+ return
+ }
+ if (endpoint === 'media:import:url') {
+ const url = String(request.body.url || '').trim()
+ if (!url.startsWith('https://')) {
+ response.json({ success: false, error: 'invalid_import_url' })
+ return
+ }
+ const mediaType = /\.(mp4|webm)(?:[?#]|$)/i.test(url) ? 'video' : 'photo'
+ const media = {
+ createdAt: Date.now(),
+ id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
+ mediaType,
+ url,
+ }
+ mockMedia.unshift(media)
+ response.json({ success: true, data: media })
+ return
+ }
if (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' })
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index f73ecb9..dc64672 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -1336,10 +1336,26 @@ Locales["en"] = {
deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.",
zoomIn = "Zoom in", zoomOut = "Zoom out", resetZoom = "Reset zoom",
filters = { all = "All", photos = "Photos", videos = "Videos" },
+ import = {
+ action = "Import", title = "Import", chooseSource = "Choose a website",
+ linkTitle = "Import from Link", linkBody = "Paste a direct link to an image or video from this website.",
+ linkLabel = "Image or video link", linkPlaceholder = "https://...", linkCompleted = "Media imported.",
+ loading = "Loading media...", emptyTitle = "No Media",
+ emptyBody = "This website has no media of this type.", loadMore = "Load More",
+ alreadyImported = "Imported", failed = "Failed", completed = "Imported {imported} media.",
+ partial = "Imported {imported} of {total} media.",
+ },
errors = {
cancelled = "The media action was cancelled.", capture_failed = "Unable to capture the game view.",
+ invalid_import_request = "The import request is invalid.", invalid_import_media = "This media item cannot be imported.",
invalid_media_type = "The media type is invalid.", invalid_upload = "The upload could not be verified.",
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Gallery uploads are not configured.",
+ import_media_not_allowed = "This media item is not allowed.", import_media_too_large = "This media item is too large.",
+ import_media_unavailable = "This media item is no longer available.", import_provider_failed = "The website could not load its media.",
+ import_provider_unauthorized = "The website credentials are invalid.", import_source_not_found = "This website is not registered.",
+ import_source_unavailable = "This website is temporarily unavailable.",
+ invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
+ import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed.",
operation_in_progress = "Another media operation is already in progress.",
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Gallery request failed.",
diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua
index 98ced28..41058e8 100644
--- a/sky_phone/config/media.lua
+++ b/sky_phone/config/media.lua
@@ -5,11 +5,53 @@ Config.Media = {
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
- ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access.
+ ApiKey = "", -- Dashboard -> Tokens -> create a token with Media access.
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
},
+ Import = {
+ Enabled = true,
+ PageSize = 30,
+ MaxSelection = 10,
+ MaxPhotoBytes = 15 * 1024 * 1024,
+ MaxVideoBytes = 150 * 1024 * 1024,
+ RevalidateAfterSeconds = 3600,
+ ListActionsPerMinute = 60,
+ ImportActionsPerMinute = 20,
+ CandidateTtlSeconds = 300,
+ ManifestCacheSeconds = 30,
+ ManifestMaxBytes = 2 * 1024 * 1024,
+ ManifestMaxItems = 5000,
+ Websites = {
+ {
+ Id = "fivemanage",
+ Label = "FiveManage",
+ Enabled = true,
+ Adapter = "fivemanage",
+ Path = "sky_phone/imports",
+ MediaTypes = { "photo", "video" },
+ -- Direct links entered in Gallery must use one of these hosts or a subdomain.
+ AllowedMediaHosts = { "fivemanage.com" },
+ },
+ --[[
+ {
+ Id = "city_media",
+ Label = "City Media",
+ Enabled = true,
+ Adapter = "manifest",
+ ManifestUrl = "https://media.example.com/sky-phone/media.json",
+ MediaTypes = { "photo", "video" },
+ AllowedMediaHosts = { "media.example.com", "cdn.example.com" },
+ Auth = {
+ Type = "bearer",
+ TokenConvar = "sky_phone_city_media_token",
+ },
+ RequiredAce = "sky_phone.import.city_media",
+ },
+ ]]
+ },
+ },
Photo = {
Encoding = "jpg",
Quality = 0.95,
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index cfc540c..06bf654 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -67,6 +67,9 @@ server_scripts {
'source/server/custom_app_storage.lua',
'source/server/sim.lua',
'source/server/calls.lua',
+ 'source/server/media_import.lua',
+ 'source/server/media_import/fivemanage.lua',
+ 'source/server/media_import/manifest.lua',
'source/server/media.lua',
'source/server/messages.lua',
'source/server/darkchat.lua',
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 19867cc..dfdb35a 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -238,6 +238,10 @@ local server_callbacks = {
"flare:send",
"gallery:list",
"media:config",
+ "media:import:sources",
+ "media:import:list",
+ "media:import:commit",
+ "media:import:url",
}
local function get_locale()
@@ -485,6 +489,15 @@ end)
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
+ if callback_name:match("^media:import:") and (not result or not result.success) then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] NUI callback '%s' failed: %s.",
+ callback_name,
+ tostring(result and result.error or "no server response"),
+ { always = true }
+ )
+ end
if result then
cb(result)
return
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 4729521..8582276 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -377,6 +377,14 @@ local schema = {
{ name = "url", type = "TEXT NOT NULL" },
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
+ { name = "origin", type = "ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload'" },
+ {
+ name = "source_id",
+ type = "VARCHAR(64) NULL",
+ characterSet = "ascii",
+ collation = "ascii_bin",
+ },
+ { name = "verified_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
@@ -2436,4 +2444,16 @@ Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_device_contact", "(`device_imei`, `contact_id`)", { unique = true })
+Bridge.Database.EnsureIndex(
+ "sky_phone_media",
+ "uniq_sky_phone_media_account_source",
+ "(`account_id`, `source_id`, `remote_id`, `origin`)",
+ { unique = true }
+)
+Bridge.Database.EnsureIndex(
+ "sky_phone_media",
+ "uniq_sky_phone_media_device_source",
+ "(`device_imei`, `source_id`, `remote_id`, `origin`)",
+ { unique = true }
+)
Bridge.Database.CompleteMigration("sky_phone")
diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua
index 7f9341a..1189817 100644
--- a/sky_phone/source/server/media.lua
+++ b/sky_phone/source/server/media.lua
@@ -1,15 +1,12 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneMedia = {}
+SkyPhoneMediaImport.Initialize()
local pending_uploads = {}
local pending_deletes = {}
-local function media_config()
- return Config.Media.FiveManage
-end
-
local function api_configured()
- local api_key = media_config().ApiKey
+ local api_key = Config.Media.FiveManage.ApiKey
return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN"
end
@@ -58,7 +55,7 @@ local function request_presigned_url()
if not api_configured() then
return nil, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET",
@@ -81,9 +78,12 @@ local function get_remote_file(remote_id)
if not api_configured() then
return nil, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
- ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
+ ("%s/%s"):format(
+ tostring(config.BaseUrl):gsub("/+$", ""),
+ SkyPhoneMediaImport.UrlEncode(remote_id)
+ ),
"GET",
"",
{ ["Authorization"] = config.ApiKey },
@@ -96,9 +96,12 @@ local function delete_remote_file(remote_id)
if not api_configured() then
return false, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
- ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
+ ("%s/%s"):format(
+ tostring(config.BaseUrl):gsub("/+$", ""),
+ SkyPhoneMediaImport.UrlEncode(remote_id)
+ ),
"DELETE",
"",
{ ["Authorization"] = config.ApiKey },
@@ -153,7 +156,9 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
params[#params + 1] = value
end
local rows = Bridge.Database.Query(([[
- SELECT `url`, `media_type` FROM `sky_phone_media`
+ SELECT `url`, `media_type`, `origin`, `source_id`, `remote_id`,
+ UNIX_TIMESTAMP(`verified_at`) AS `verified_at`
+ FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -172,6 +177,33 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
)
return nil, "invalid_attachment"
end
+ if media.origin == "website_import" then
+ local verified_at = tonumber(media.verified_at) or 0
+ local revalidate_after = math.max(
+ 1,
+ math.floor(tonumber(Config.Media.Import.RevalidateAfterSeconds) or 3600)
+ )
+ if os.time() - verified_at >= revalidate_after then
+ local refreshed, refresh_error
+ if type(media.remote_id) == "string" and media.remote_id:sub(1, 4) == "url:" then
+ refreshed, refresh_error = SkyPhoneMediaImport.ResolveUrl(media.source_id, media.url)
+ else
+ refreshed, refresh_error = SkyPhoneMediaImport.Resolve(media.source_id, media.remote_id)
+ end
+ if not refreshed then
+ return nil, refresh_error
+ end
+ if refreshed.mediaType ~= media_type then
+ return nil, "import_media_not_allowed"
+ end
+ Bridge.Database.Query([[
+ UPDATE `sky_phone_media`
+ SET `url` = ?, `verified_at` = CURRENT_TIMESTAMP
+ WHERE `id` = ? AND `origin` = 'website_import'
+ ]], { refreshed.url, id })
+ media.url = refreshed.url
+ end
+ end
return media.url
end
@@ -467,7 +499,7 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
photo = Config.Media.Photo,
presignedUrl = presigned_url,
requestId = request_id,
- uploadTimeoutMs = media_config().UploadTimeoutMs,
+ uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
video = Config.Media.Video,
})
end)
@@ -572,7 +604,7 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
query_params[#query_params + 1] = value
end
local rows = Bridge.Database.Query(([[
- SELECT `id`, `remote_id` FROM `sky_phone_media`
+ SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
WHERE `id` = ? AND %s LIMIT 1
]]):format(condition), query_params)
local row = rows[1]
@@ -585,11 +617,13 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
return
end
pending_deletes[media_id] = src
- local deleted, delete_error = delete_remote_file(row.remote_id)
- if not deleted then
- pending_deletes[media_id] = nil
- delete_result(src, correlation_id, false, delete_error, media_id)
- return
+ if row.origin == "phone_upload" then
+ local deleted, delete_error = delete_remote_file(row.remote_id)
+ if not deleted then
+ pending_deletes[media_id] = nil
+ delete_result(src, correlation_id, false, delete_error, media_id)
+ return
+ end
end
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
pending_deletes[media_id] = nil
@@ -599,7 +633,7 @@ end)
function SkyPhoneMedia.GetDeviceRemoteIds(imei)
local rows = Bridge.Database.Query([[
SELECT `id`, `remote_id` FROM `sky_phone_media`
- WHERE `account_id` IS NULL AND `device_imei` = ?
+ WHERE `account_id` IS NULL AND `device_imei` = ? AND `origin` = 'phone_upload'
]], { imei })
return rows
end
@@ -630,6 +664,6 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
- print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
+ print("^3[sky_phone] Camera uploads and FiveManage imports are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
end
end)
diff --git a/sky_phone/source/server/media_import.lua b/sky_phone/source/server/media_import.lua
new file mode 100644
index 0000000..4e96a59
--- /dev/null
+++ b/sky_phone/source/server/media_import.lua
@@ -0,0 +1,725 @@
+SkyPhoneMediaImport = {}
+
+local adapters = {}
+local websites = {}
+local import_candidates = {}
+local initialized = false
+local media_types_by_mime = {
+ ["image/gif"] = "photo",
+ ["image/jpeg"] = "photo",
+ ["image/png"] = "photo",
+ ["image/webp"] = "photo",
+ ["video/mp4"] = "video",
+ ["video/quicktime"] = "video",
+ ["video/webm"] = "video",
+}
+
+local function session_owner(source)
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return nil, error_response
+ end
+
+ local device = SkyPhone.LoadDevice(session.imei)
+ if not device then
+ return nil, { success = false, error = "device_not_found" }
+ end
+
+ return {
+ account_id = device.account_id and tonumber(device.account_id) or nil,
+ imei = session.imei,
+ }
+end
+
+local function owner_condition(owner)
+ if owner.account_id then
+ return "`account_id` = ?", { owner.account_id }
+ end
+
+ return "`account_id` IS NULL AND `device_imei` = ?", { owner.imei }
+end
+
+local function valid_source_id(value)
+ return type(value) == "string"
+ and #value >= 1
+ and #value <= 64
+ and value:match("^[a-z0-9_%-]+$") ~= nil
+end
+
+local function valid_external_id(value)
+ return type(value) == "string"
+ and #value >= 1
+ and #value <= 128
+ and value:match("^[%w_.:%-]+$") ~= nil
+end
+
+local function website_accessible(source, website)
+ return not website.RequiredAce or website.RequiredAce == "" or IsPlayerAceAllowed(source, website.RequiredAce)
+end
+
+local function media_type_set(values)
+ local allowed = {}
+ if type(values) ~= "table" then
+ return allowed
+ end
+
+ for _, value in ipairs(values) do
+ if value == "photo" or value == "video" then
+ allowed[value] = true
+ end
+ end
+ return allowed
+end
+
+local function url_host(value)
+ if type(value) ~= "string" or #value > Config.Media.UrlMaxLength or value:find("%c") then
+ return nil
+ end
+
+ local authority = value:match("^https://([^/%?#]+)")
+ if not authority or authority:find("@", 1, true) then
+ return nil
+ end
+
+ local host = authority:match("^([^:]+)")
+ return host and host:lower() or nil
+end
+
+function SkyPhoneMediaImport.ResponseHeader(headers, name)
+ if type(headers) ~= "table" then
+ return nil
+ end
+ local requested_name = name:lower()
+ for header_name, value in pairs(headers) do
+ if type(header_name) == "string" and header_name:lower() == requested_name then
+ if type(value) == "table" then
+ return value[1]
+ end
+ return value
+ end
+ end
+ return nil
+end
+
+local function allowed_host(website, host)
+ for _, configured_host in ipairs(website.AllowedMediaHosts) do
+ local candidate = type(configured_host) == "string" and configured_host:lower():gsub("^%.", "") or ""
+ if candidate ~= "" and (host == candidate or host:sub(-#candidate - 1) == "." .. candidate) then
+ return true
+ end
+ end
+ return false
+end
+
+local function normalize_media(website, item)
+ if type(item) ~= "table" or not valid_external_id(item.externalId) then
+ return nil, "invalid_import_media"
+ end
+
+ local media_type = item.mediaType
+ if not website._media_types[media_type] then
+ return nil, "import_media_not_allowed"
+ end
+
+ local size = tonumber(item.size)
+ if not size or size <= 0 or size ~= math.floor(size) then
+ return nil, "invalid_import_media"
+ end
+
+ local size_limit = media_type == "photo"
+ and tonumber(Config.Media.Import.MaxPhotoBytes)
+ or tonumber(Config.Media.Import.MaxVideoBytes)
+ if not size_limit or size > size_limit then
+ return nil, "import_media_too_large"
+ end
+
+ local host = url_host(item.url)
+ if not host or not allowed_host(website, host) then
+ return nil, "import_media_not_allowed"
+ end
+
+ local filename = type(item.filename) == "string" and item.filename:match("^%s*(.-)%s*$") or ""
+ if filename == "" then
+ filename = item.externalId
+ elseif #filename > 160 then
+ filename = filename:sub(1, 160)
+ end
+
+ return {
+ externalId = item.externalId,
+ filename = filename,
+ mediaType = media_type,
+ size = size,
+ sourceId = website.Id,
+ url = item.url,
+ }
+end
+
+local function remember_candidate(source, media)
+ local expires_at = os.time() + math.max(
+ 30,
+ math.floor(tonumber(Config.Media.Import.CandidateTtlSeconds) or 300)
+ )
+ import_candidates[source] = import_candidates[source] or {}
+ import_candidates[source][media.sourceId] = import_candidates[source][media.sourceId] or {}
+ import_candidates[source][media.sourceId][media.externalId] = expires_at
+end
+
+local function candidate_allowed(source, source_id, external_id)
+ local by_source = import_candidates[source] and import_candidates[source][source_id]
+ local expires_at = by_source and by_source[external_id]
+ if not expires_at or expires_at < os.time() then
+ if by_source then
+ by_source[external_id] = nil
+ end
+ return false
+ end
+ return true
+end
+
+local function validate_website(definition)
+ if type(definition) ~= "table"
+ or definition.Enabled == false
+ or not valid_source_id(definition.Id)
+ or type(definition.Label) ~= "string"
+ or definition.Label == ""
+ or #definition.Label > 64
+ or type(definition.Adapter) ~= "string"
+ then
+ return nil, "invalid_definition"
+ end
+
+ local adapter = adapters[definition.Adapter]
+ if not adapter then
+ return nil, "unknown_adapter"
+ end
+
+ if type(definition.AllowedMediaHosts) ~= "table" or #definition.AllowedMediaHosts < 1 then
+ return nil, "missing_allowed_hosts"
+ end
+
+ local allowed_media_types = media_type_set(definition.MediaTypes)
+ if not allowed_media_types.photo and not allowed_media_types.video then
+ return nil, "missing_media_types"
+ end
+
+ if definition.RequiredAce ~= nil and type(definition.RequiredAce) ~= "string" then
+ return nil, "invalid_required_ace"
+ end
+
+ definition._adapter = adapter
+ definition._media_types = allowed_media_types
+ local valid, validation_error = adapter.Validate(definition)
+ if not valid then
+ return nil, validation_error
+ end
+
+ return definition
+end
+
+local function build_registry()
+ websites = {}
+ local config = Config.Media.Import
+ if not config.Enabled then
+ return
+ end
+
+ for index, definition in ipairs(config.Websites or {}) do
+ local website, website_error = validate_website(definition)
+ if website then
+ if websites[website.Id] then
+ error(("[sky_phone] Duplicate media import website id '%s'."):format(website.Id))
+ end
+ websites[website.Id] = website
+ else
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import website at index %s was disabled: %s.",
+ tostring(index),
+ tostring(website_error)
+ )
+ end
+ end
+end
+
+local function find_imported(owner, source_id, items)
+ if #items == 0 then
+ return
+ end
+
+ local condition, params = owner_condition(owner)
+ params[#params + 1] = source_id
+ local placeholders = {}
+ for index, item in ipairs(items) do
+ placeholders[index] = "?"
+ params[#params + 1] = item.externalId
+ end
+
+ local rows = Bridge.Database.Query(([[
+ SELECT `remote_id` FROM `sky_phone_media`
+ WHERE %s AND `origin` = 'website_import' AND `source_id` = ?
+ AND `remote_id` IN (%s)
+ ]]):format(condition, table.concat(placeholders, ", ")), params)
+ local imported = {}
+ for _, row in ipairs(rows) do
+ imported[row.remote_id] = true
+ end
+ for _, item in ipairs(items) do
+ item.imported = imported[item.externalId] or false
+ end
+end
+
+local function select_owned_import(owner, source_id, remote_id)
+ local condition, params = owner_condition(owner)
+ params[#params + 1] = source_id
+ params[#params + 1] = remote_id
+ local rows = Bridge.Database.Query(([[
+ SELECT `id`, `url`, `media_type` AS `mediaType`,
+ UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt`
+ FROM `sky_phone_media`
+ WHERE %s AND `origin` = 'website_import'
+ AND `source_id` = ? AND `remote_id` = ?
+ LIMIT 1
+ ]]):format(condition), params)
+ local row = rows[1]
+ if row then
+ row.id = tonumber(row.id)
+ row.createdAt = tonumber(row.createdAt) or 0
+ end
+ return row
+end
+
+local function store_import(owner, media)
+ local existing = select_owned_import(owner, media.sourceId, media.externalId)
+ if existing then
+ Bridge.Database.Query([[
+ UPDATE `sky_phone_media`
+ SET `url` = ?, `media_type` = ?, `verified_at` = CURRENT_TIMESTAMP
+ WHERE `id` = ?
+ ]], { media.url, media.mediaType, existing.id })
+ existing.url = media.url
+ existing.mediaType = media.mediaType
+ return existing
+ end
+
+ local result
+ if owner.account_id then
+ result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_media`
+ (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`)
+ VALUES (?, NULL, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
+ ]], { owner.account_id, media.url, media.externalId, media.mediaType, media.sourceId })
+ else
+ result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_media`
+ (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`)
+ VALUES (NULL, ?, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
+ ]], { owner.imei, media.url, media.externalId, media.mediaType, media.sourceId })
+ end
+
+ local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
+ if media_id and media_id < 1 then
+ media_id = nil
+ end
+ if not media_id then
+ local concurrent = select_owned_import(owner, media.sourceId, media.externalId)
+ if concurrent then
+ return concurrent
+ end
+ return nil, "request_failed"
+ end
+
+ return {
+ createdAt = os.time() * 1000,
+ id = media_id,
+ mediaType = media.mediaType,
+ url = media.url,
+ }
+end
+
+function SkyPhoneMediaImport.RegisterAdapter(name, adapter)
+ assert(type(name) == "string" and name ~= "", "Media import adapter name must be a string")
+ assert(type(adapter) == "table", "Media import adapter must be a table")
+ assert(type(adapter.Validate) == "function", "Media import adapter requires Validate")
+ assert(type(adapter.List) == "function", "Media import adapter requires List")
+ assert(type(adapter.Resolve) == "function", "Media import adapter requires Resolve")
+ assert(not adapters[name], ("Media import adapter '%s' is already registered"):format(name))
+ adapters[name] = adapter
+end
+
+function SkyPhoneMediaImport.HttpRequest(url, headers, timeout_ms, method)
+ local request = promise.new()
+ local settled = false
+ local request_method = method or "GET"
+ local request_host = url_host(url) or "invalid-host"
+ PerformHttpRequest(url, function(status, response_body, response_headers, error_data)
+ if settled then
+ return
+ end
+ settled = true
+ local response_status = tonumber(status) or 0
+ if response_status == 0 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import HTTP %s request to '%s' failed: %s.",
+ request_method,
+ request_host,
+ tostring(error_data or "unknown transport error"),
+ { always = true }
+ )
+ elseif response_status >= 400 then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] Media import HTTP %s request to '%s' returned status %s.",
+ request_method,
+ request_host,
+ tostring(response_status)
+ )
+ end
+ request:resolve({
+ body = response_body or "",
+ error = error_data,
+ headers = response_headers or {},
+ status = response_status,
+ })
+ end, request_method, "", headers or {}, { followLocation = false })
+ SetTimeout(timeout_ms, function()
+ if settled then
+ return
+ end
+ settled = true
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import HTTP %s request to '%s' timed out after %s ms.",
+ request_method,
+ request_host,
+ tostring(timeout_ms),
+ { always = true }
+ )
+ request:resolve({ body = "", error = "request timeout", headers = {}, status = 0 })
+ end)
+ return Citizen.Await(request)
+end
+
+function SkyPhoneMediaImport.ResolveUrl(source_id, url)
+ if not initialized or not valid_source_id(source_id) or type(url) ~= "string" then
+ return nil, "invalid_import_url"
+ end
+
+ local website = websites[source_id]
+ local trimmed_url = url:match("^%s*(.-)%s*$")
+ local host = website and url_host(trimmed_url) or nil
+ if not website or not host or not allowed_host(website, host) then
+ return nil, "import_url_not_allowed"
+ end
+
+ if type(website._adapter.ResolveUrl) == "function" then
+ local item, resolve_error = website._adapter.ResolveUrl(website, trimmed_url)
+ if not item then
+ return nil, resolve_error
+ end
+ return normalize_media(website, item)
+ end
+
+ local response = SkyPhoneMediaImport.HttpRequest(
+ trimmed_url,
+ {},
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000,
+ "HEAD"
+ )
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status < 200 or response.status >= 300 then
+ return nil, "import_url_unavailable"
+ end
+
+ local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ content_type = type(content_type) == "string" and content_type:lower():match("^%s*([^;%s]+)") or nil
+ local media_type = content_type and media_types_by_mime[content_type] or nil
+ if not media_type or not website._media_types[media_type] then
+ return nil, "import_media_not_allowed"
+ end
+
+ local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+ if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
+ return nil, "import_size_unavailable"
+ end
+
+ local external_id = ("url:%08x%08x"):format(
+ joaat(trimmed_url) & 0xffffffff,
+ joaat("sky_phone:" .. trimmed_url) & 0xffffffff
+ )
+ local url_path = trimmed_url:match("^https://[^/]+(/[^?#]*)") or ""
+ return normalize_media(website, {
+ externalId = external_id,
+ filename = url_path:match("/([^/]+)$") or external_id,
+ mediaType = media_type,
+ size = content_length,
+ url = trimmed_url,
+ })
+end
+
+function SkyPhoneMediaImport.UrlEncode(value)
+ return tostring(value):gsub("\n", "\r\n"):gsub("([^%w%-_%.~])", function(character)
+ return ("%%%02X"):format(character:byte())
+ end)
+end
+
+function SkyPhoneMediaImport.Resolve(source_id, external_id)
+ if not initialized or not valid_source_id(source_id) or not valid_external_id(external_id) then
+ return nil, "import_source_unavailable"
+ end
+
+ local website = websites[source_id]
+ if not website then
+ return nil, "import_source_unavailable"
+ end
+
+ local item, resolve_error = website._adapter.Resolve(website, external_id)
+ if not item then
+ return nil, resolve_error
+ end
+
+ return normalize_media(website, item)
+end
+
+function SkyPhoneMediaImport.Initialize()
+ assert(not initialized, "Media import was initialized more than once")
+ build_registry()
+ initialized = true
+
+ Bridge.Callbacks.Register("sky_phone:media:import:sources", function(source)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+
+ local sources = {}
+ for _, website in pairs(websites) do
+ if website_accessible(source, website) then
+ local media_types = {}
+ if website._media_types.photo then
+ media_types[#media_types + 1] = "photo"
+ end
+ if website._media_types.video then
+ media_types[#media_types + 1] = "video"
+ end
+ sources[#sources + 1] = {
+ id = website.Id,
+ label = website.Label,
+ mediaTypes = media_types,
+ }
+ end
+ end
+ table.sort(sources, function(left, right)
+ return left.label:lower() < right.label:lower()
+ end)
+ return {
+ success = true,
+ data = {
+ maxSelection = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1)),
+ sources = sources,
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:list", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_list",
+ tonumber(Config.Media.Import.ListActionsPerMinute) or 60,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ local media_type = data.mediaType
+ local page = math.floor(tonumber(data.page) or 1)
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if not website._media_types[media_type] or page < 1 or page > 10000 then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local limit = math.max(1, math.min(math.floor(tonumber(Config.Media.Import.PageSize) or 30), 100))
+ local result, list_error = website._adapter.List(website, media_type, page, limit)
+ if not result then
+ return { success = false, error = list_error }
+ end
+ if type(result) ~= "table" or type(result.items) ~= "table" then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Import source '%s' returned an invalid list response.",
+ website.Id
+ )
+ return { success = false, error = "import_provider_failed" }
+ end
+
+ local items = {}
+ for _, item in ipairs(result.items) do
+ local normalized, normalize_error = normalize_media(website, item)
+ if normalized then
+ items[#items + 1] = normalized
+ remember_candidate(source, normalized)
+ else
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Rejected media '%s' from import source '%s': %s.",
+ tostring(item.externalId),
+ website.Id,
+ tostring(normalize_error)
+ )
+ end
+ end
+ find_imported(owner, website.Id, items)
+ return {
+ success = true,
+ data = {
+ hasMore = result.hasMore == true,
+ items = items,
+ page = page,
+ total = math.max(0, math.floor(tonumber(result.total) or #items)),
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:commit", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_commit",
+ tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if type(data.externalIds) ~= "table" then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local maximum = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1))
+ if #data.externalIds < 1 or #data.externalIds > maximum then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local unique_ids = {}
+ local requested_ids = {}
+ for _, external_id in ipairs(data.externalIds) do
+ if not valid_external_id(external_id) or unique_ids[external_id] then
+ return { success = false, error = "invalid_import_request" }
+ end
+ if not candidate_allowed(source, website.Id, external_id) then
+ return { success = false, error = "invalid_import_request" }
+ end
+ unique_ids[external_id] = true
+ requested_ids[#requested_ids + 1] = external_id
+ end
+
+ local imported = {}
+ local failed = {}
+ for _, external_id in ipairs(requested_ids) do
+ local item, resolve_error = website._adapter.Resolve(website, external_id)
+ local normalized, normalize_error
+ if item then
+ normalized, normalize_error = normalize_media(website, item)
+ end
+ if not normalized then
+ failed[#failed + 1] = {
+ error = resolve_error or normalize_error or "import_provider_failed",
+ externalId = external_id,
+ }
+ else
+ local stored, store_error = store_import(owner, normalized)
+ if stored then
+ imported[#imported + 1] = stored
+ else
+ failed[#failed + 1] = {
+ error = store_error or "request_failed",
+ externalId = external_id,
+ }
+ end
+ end
+ end
+
+ return {
+ success = true,
+ data = {
+ failed = failed,
+ imported = imported,
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:url", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_url",
+ tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if type(data.url) ~= "string" or #data.url < 1 or #data.url > Config.Media.UrlMaxLength then
+ return { success = false, error = "invalid_import_url" }
+ end
+
+ local normalized, resolve_error = SkyPhoneMediaImport.ResolveUrl(website.Id, data.url)
+ if not normalized then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media URL import failed for player %s, source '%s', host '%s': %s.",
+ tostring(source),
+ website.Id,
+ tostring(url_host(data.url) or "invalid-host"),
+ tostring(resolve_error),
+ { always = true }
+ )
+ return { success = false, error = resolve_error }
+ end
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] Media URL import resolved for player %s via source '%s' as %s '%s' (%s bytes).",
+ tostring(source),
+ website.Id,
+ normalized.mediaType,
+ normalized.externalId,
+ tostring(normalized.size)
+ )
+ local stored, store_error = store_import(owner, normalized)
+ if not stored then
+ return { success = false, error = store_error or "request_failed" }
+ end
+ return { success = true, data = stored }
+ end)
+
+ AddEventHandler("playerDropped", function()
+ import_candidates[source] = nil
+ end)
+end
diff --git a/sky_phone/source/server/media_import/fivemanage.lua b/sky_phone/source/server/media_import/fivemanage.lua
new file mode 100644
index 0000000..33a14ab
--- /dev/null
+++ b/sky_phone/source/server/media_import/fivemanage.lua
@@ -0,0 +1,237 @@
+local function api_key(website)
+ local value = website.ApiKey or Config.Media.FiveManage.ApiKey
+ if type(value) ~= "string" or value == "" or value == "YOUR_API_TOKEN" then
+ return ""
+ end
+ return value
+end
+
+local function provider_error(response, not_found_error)
+ if response.status == 0 then
+ return "import_source_unavailable"
+ end
+ if response.status == 401 or response.status == 403 then
+ return "import_provider_unauthorized"
+ end
+ if response.status == 404 and not_found_error then
+ return not_found_error
+ end
+ return "import_provider_failed"
+end
+
+local function decode_response(response, not_found_error)
+ if type(response) ~= "table" or response.status < 200 or response.status >= 300 then
+ return nil, provider_error(response or { status = 0 }, not_found_error)
+ end
+
+ local success, decoded = pcall(json.decode, response.body or "")
+ if not success or type(decoded) ~= "table" then
+ return nil, "import_provider_failed"
+ end
+ return decoded
+end
+
+local function media_type(value)
+ local normalized = type(value) == "string" and value:lower() or ""
+ if normalized == "image" or normalized:find("image/", 1, true) == 1 then
+ return "photo"
+ end
+ if normalized == "video" or normalized:find("video/", 1, true) == 1 then
+ return "video"
+ end
+ return nil
+end
+
+local media_extensions = {
+ gif = true,
+ jpeg = true,
+ jpg = true,
+ mov = true,
+ mp4 = true,
+ png = true,
+ webm = true,
+ webp = true,
+}
+
+local function public_file_id(url)
+ local path = type(url) == "string" and url:match("^https://[^/%?#]+(/[^?#]*)") or nil
+ local segment = path and path:match("/([^/]+)$") or nil
+ if not segment or segment == "" or segment:find("%", 1, true) then
+ return nil
+ end
+
+ local extension = segment:match("%.([%w]+)$")
+ if extension and media_extensions[extension:lower()] then
+ segment = segment:sub(1, -#extension - 2)
+ end
+ if #segment < 1 or #segment > 128 or not segment:match("^[%w_%-]+$") then
+ return nil
+ end
+ return segment
+end
+
+local function normalize_file(file)
+ if type(file) ~= "table" then
+ return {}
+ end
+ return {
+ externalId = file.id,
+ filename = file.filename,
+ mediaType = media_type(file.type or file.mimeType),
+ size = file.size,
+ url = file.url,
+ }
+end
+
+local function resolve_file(website, external_id)
+ local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ local response = SkyPhoneMediaImport.HttpRequest(
+ ("%s/%s"):format(provider_url, SkyPhoneMediaImport.UrlEncode(external_id)),
+ { ["Authorization"] = api_key(website) },
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ )
+ local decoded, response_error = decode_response(response, "import_media_unavailable")
+ if not decoded then
+ return nil, response_error
+ end
+
+ local file = type(decoded.data) == "table" and decoded.data or decoded
+ if file.id ~= external_id then
+ return nil, "invalid_import_media"
+ end
+ return normalize_file(file)
+end
+
+local function probe_public_url(website, url)
+ local timeout = tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ local response = SkyPhoneMediaImport.HttpRequest(url, {}, timeout, "HEAD")
+ local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+
+ if response.status == 0 or (response.status >= 200 and response.status < 300
+ and (not content_type or not content_length or content_length <= 0))
+ then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage HEAD probe did not provide usable metadata; trying a one-byte range request."
+ )
+ response = SkyPhoneMediaImport.HttpRequest(url, { ["Range"] = "bytes=0-0" }, timeout)
+ content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ local content_range = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-range")
+ content_length = type(content_range) == "string" and tonumber(content_range:match("/(%d+)$"))
+ or tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+ end
+
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status < 200 or response.status >= 300 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] FiveManage public URL probe returned HTTP %s.",
+ tostring(response.status),
+ { always = true }
+ )
+ return nil, "import_url_unavailable"
+ end
+
+ local normalized_type = media_type(content_type)
+ if not normalized_type then
+ return nil, "import_media_not_allowed"
+ end
+ if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
+ return nil, "import_size_unavailable"
+ end
+
+ local url_path = url:match("^https://[^/]+(/[^?#]*)") or ""
+ local external_id = ("url:%08x%08x"):format(
+ joaat(url) & 0xffffffff,
+ joaat("sky_phone:" .. url) & 0xffffffff
+ )
+ return {
+ externalId = external_id,
+ filename = url_path:match("/([^/]+)$") or external_id,
+ mediaType = normalized_type,
+ size = content_length,
+ url = url,
+ }
+end
+
+SkyPhoneMediaImport.RegisterAdapter("fivemanage", {
+ Validate = function(website)
+ local url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ if not url:match("^https://") or #url > Config.Media.UrlMaxLength then
+ return false, "invalid_base_url"
+ end
+ if type(website.Path) ~= "string" or website.Path == "" or #website.Path > 180 then
+ return false, "missing_import_path"
+ end
+ if api_key(website) == "" then
+ return false, "missing_api_key"
+ end
+ return true
+ end,
+
+ List = function(website, requested_type, page, limit)
+ local provider_type = requested_type == "photo" and "image" or "video"
+ local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ local url = ("%s?page=%s&limit=%s&type=%s&path=%s"):format(
+ provider_url,
+ page,
+ limit,
+ provider_type,
+ SkyPhoneMediaImport.UrlEncode(website.Path)
+ )
+ local response = SkyPhoneMediaImport.HttpRequest(
+ url,
+ { ["Authorization"] = api_key(website) },
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ )
+ local decoded, response_error = decode_response(response)
+ if not decoded then
+ return nil, response_error
+ end
+
+ local files = type(decoded.data) == "table" and decoded.data or {}
+ local items = {}
+ for _, file in ipairs(files) do
+ items[#items + 1] = normalize_file(file)
+ end
+ local pagination = type(decoded.pagination) == "table" and decoded.pagination or {}
+ local total = math.max(0, math.floor(tonumber(pagination.total) or #items))
+ local current_page = math.max(1, math.floor(tonumber(pagination.page) or page))
+ local page_limit = math.max(1, math.floor(tonumber(pagination.limit) or limit))
+ return {
+ hasMore = current_page * page_limit < total,
+ items = items,
+ total = total,
+ }
+ end,
+
+ Resolve = resolve_file,
+
+ ResolveUrl = function(website, url)
+ local external_id = public_file_id(url)
+ if external_id then
+ local file, resolve_error = resolve_file(website, external_id)
+ if file then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage URL resolved through authenticated metadata for file '%s'.",
+ external_id
+ )
+ return file
+ end
+ if resolve_error == "import_provider_unauthorized" then
+ return nil, resolve_error
+ end
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage metadata lookup for file '%s' failed with '%s'; probing the public URL.",
+ external_id,
+ tostring(resolve_error)
+ )
+ end
+ return probe_public_url(website, url)
+ end,
+})
diff --git a/sky_phone/source/server/media_import/manifest.lua b/sky_phone/source/server/media_import/manifest.lua
new file mode 100644
index 0000000..dbdfbe2
--- /dev/null
+++ b/sky_phone/source/server/media_import/manifest.lua
@@ -0,0 +1,152 @@
+local manifest_cache = {}
+
+local function authentication_headers(website)
+ local auth = website.Auth
+ if not auth or auth.Type == nil or auth.Type == "none" then
+ return {}
+ end
+
+ if auth.Type == "bearer" then
+ return { ["Authorization"] = "Bearer " .. GetConvar(auth.TokenConvar, "") }
+ end
+ return { [auth.Header] = GetConvar(auth.ValueConvar, "") }
+end
+
+local function validate_authentication(auth)
+ if auth == nil then
+ return true
+ end
+ if type(auth) ~= "table" then
+ return false, "invalid_auth"
+ end
+ if auth.Type == nil or auth.Type == "none" then
+ return true
+ end
+ if auth.Type == "bearer" then
+ if type(auth.TokenConvar) ~= "string" or auth.TokenConvar == ""
+ or GetConvar(auth.TokenConvar, "") == ""
+ then
+ return false, "missing_auth_convar"
+ end
+ return true
+ end
+ if auth.Type == "header" then
+ if type(auth.Header) ~= "string" or not auth.Header:match("^[%w%-]+$")
+ or type(auth.ValueConvar) ~= "string" or auth.ValueConvar == ""
+ or GetConvar(auth.ValueConvar, "") == ""
+ then
+ return false, "invalid_header_auth"
+ end
+ return true
+ end
+ return false, "unknown_auth_type"
+end
+
+local function fetch_manifest(website)
+ local now = os.time()
+ local cached = manifest_cache[website.Id]
+ if cached and cached.expires_at > now then
+ return cached.items
+ end
+
+ local response = SkyPhoneMediaImport.HttpRequest(
+ website.ManifestUrl,
+ authentication_headers(website),
+ tonumber(website.RequestTimeoutMs) or 10000
+ )
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status == 401 or response.status == 403 then
+ return nil, "import_provider_unauthorized"
+ end
+ if response.status < 200 or response.status >= 300 then
+ return nil, "import_provider_failed"
+ end
+
+ local max_bytes = math.max(1024, math.floor(tonumber(Config.Media.Import.ManifestMaxBytes) or 2097152))
+ if #response.body > max_bytes then
+ return nil, "import_provider_failed"
+ end
+
+ local success, decoded = pcall(json.decode, response.body)
+ if not success or type(decoded) ~= "table" or tonumber(decoded.version) ~= 1
+ or type(decoded.items) ~= "table"
+ then
+ return nil, "import_provider_failed"
+ end
+
+ local maximum_items = math.max(1, math.floor(tonumber(Config.Media.Import.ManifestMaxItems) or 5000))
+ if #decoded.items > maximum_items then
+ return nil, "import_provider_failed"
+ end
+
+ local items = {}
+ for _, item in ipairs(decoded.items) do
+ if type(item) == "table" then
+ items[#items + 1] = {
+ externalId = item.id,
+ filename = item.filename,
+ mediaType = item.type,
+ size = item.size,
+ url = item.url,
+ }
+ end
+ end
+ manifest_cache[website.Id] = {
+ expires_at = now + math.max(1, math.floor(tonumber(website.CacheSeconds)
+ or tonumber(Config.Media.Import.ManifestCacheSeconds) or 30)),
+ items = items,
+ }
+ return items
+end
+
+SkyPhoneMediaImport.RegisterAdapter("manifest", {
+ Validate = function(website)
+ if type(website.ManifestUrl) ~= "string"
+ or not website.ManifestUrl:match("^https://")
+ or #website.ManifestUrl > Config.Media.UrlMaxLength
+ then
+ return false, "invalid_manifest_url"
+ end
+ return validate_authentication(website.Auth)
+ end,
+
+ List = function(website, requested_type, page, limit)
+ local manifest, manifest_error = fetch_manifest(website)
+ if not manifest then
+ return nil, manifest_error
+ end
+
+ local matching = {}
+ for _, item in ipairs(manifest) do
+ if item.mediaType == requested_type then
+ matching[#matching + 1] = item
+ end
+ end
+ local first = (page - 1) * limit + 1
+ local last = math.min(#matching, first + limit - 1)
+ local items = {}
+ for index = first, last do
+ items[#items + 1] = matching[index]
+ end
+ return {
+ hasMore = last < #matching,
+ items = items,
+ total = #matching,
+ }
+ end,
+
+ Resolve = function(website, external_id)
+ local manifest, manifest_error = fetch_manifest(website)
+ if not manifest then
+ return nil, manifest_error
+ end
+ for _, item in ipairs(manifest) do
+ if item.externalId == external_id then
+ return item
+ end
+ end
+ return nil, "import_media_unavailable"
+ end,
+})
diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql
index 4e4230b..79ecaf0 100644
--- a/sky_phone/sql/install.sql
+++ b/sky_phone/sql/install.sql
@@ -153,8 +153,13 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
`url` TEXT NOT NULL,
`remote_id` VARCHAR(128) NOT NULL,
`media_type` ENUM('photo', 'video') NOT NULL,
+ `origin` ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload',
+ `source_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
+ `verified_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_sky_phone_media_account_source` (`account_id`, `source_id`, `remote_id`, `origin`),
+ UNIQUE KEY `uniq_sky_phone_media_device_source` (`device_imei`, `source_id`, `remote_id`, `origin`),
KEY `idx_sky_phone_media_account` (`account_id`, `created_at`, `id`),
KEY `idx_sky_phone_media_device` (`device_imei`, `created_at`, `id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
From f6664bf4cf51446f7a6fece1ad957e8c2d1e444e Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt"
Date: Thu, 13 Aug 2026 01:55:07 +0200
Subject: [PATCH 46/63] ADD - added Extern Image/Video Import
---
README.md | 6 +-
frontend/src/stores/phone.ts | 32 +
frontend/src/types/media.ts | 38 +
frontend/src/utils/media.test.ts | 12 +
frontend/src/utils/media.ts | 26 +
frontend/src/views/apps/GalleryApp.vue | 253 +++++-
frontend/testserver/index.cjs | 120 ++-
sky_phone/config/locales/en.lua | 16 +
sky_phone/config/media.lua | 42 +
sky_phone/fxmanifest.lua | 3 +
sky_phone/source/client/main.lua | 13 +
sky_phone/source/server/db_migrate.lua | 20 +
sky_phone/source/server/media.lua | 86 +-
sky_phone/source/server/media_import.lua | 746 ++++++++++++++++++
.../source/server/media_import/fivemanage.lua | 240 ++++++
.../source/server/media_import/manifest.lua | 153 ++++
sky_phone/sql/install.sql | 6 +
17 files changed, 1782 insertions(+), 30 deletions(-)
create mode 100644 sky_phone/source/server/media_import.lua
create mode 100644 sky_phone/source/server/media_import/fivemanage.lua
create mode 100644 sky_phone/source/server/media_import/manifest.lua
diff --git a/README.md b/README.md
index 5c97335..8fb71fd 100644
--- a/README.md
+++ b/README.md
@@ -209,7 +209,11 @@ Database migrations run automatically. Existing `sky_phone_mail_accounts` instal
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees
them. Signing out hides cloud media without deleting it. Factory reset removes device-local media
-and attempts to delete its remote FiveManage files, while account-owned media remains in the cloud.
+and attempts to delete only Phone-created remote FiveManage files. Media imported from a website is
+removed locally but never deleted at its source. Register import websites under
+`Config.Media.Import.Websites` in `sky_phone/config/media.lua`; built-in adapters support FiveManage
+files and version-1 JSON manifests. The Gallery import form accepts direct HTTPS image/video links
+only when their hostname matches the selected website's `AllowedMediaHosts`.
For a fresh manual database installation, import `sky_phone/sql/install.sql`. It contains the complete current table, key, index, collation, and foreign-key schema. Runtime migrations remain authoritative for upgrading an existing installation and must stay enabled.
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 611035a..8de3c3b 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -3432,13 +3432,45 @@ const defaultLocales: LocaleTree = {
zoomOut: 'Zoom out',
resetZoom: 'Reset zoom',
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
+ import: {
+ action: 'Import',
+ title: 'Import',
+ chooseSource: 'Choose a website',
+ linkTitle: 'Import from Link',
+ linkBody:
+ 'Paste a direct link to an image or video from this website.',
+ linkLabel: 'Image or video link',
+ linkPlaceholder: 'https://...',
+ linkCompleted: 'Media imported.',
+ loading: 'Loading media...',
+ emptyTitle: 'No Media',
+ emptyBody: 'This website has no media of this type.',
+ loadMore: 'Load More',
+ alreadyImported: 'Imported',
+ failed: 'Failed',
+ completed: 'Imported {imported} media.',
+ partial: 'Imported {imported} of {total} media.',
+ },
errors: {
cancelled: 'The media action was cancelled.',
capture_failed: 'Unable to capture the game view.',
+ invalid_import_request: 'The import request is invalid.',
+ invalid_import_media: 'This media item cannot be imported.',
invalid_media_type: 'The media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Gallery uploads are not configured.',
+ import_media_not_allowed: 'This media item is not allowed.',
+ import_media_too_large: 'This media item is too large.',
+ import_media_unavailable: 'This media item is no longer available.',
+ import_provider_failed: 'The website could not load its media.',
+ import_provider_unauthorized: 'The website credentials are invalid.',
+ import_source_not_found: 'This website is not registered.',
+ import_source_unavailable: 'This website is temporarily unavailable.',
+ invalid_import_url: 'Enter a valid HTTPS media link.',
+ import_url_not_allowed: 'This link is not from the selected website.',
+ import_url_unavailable: 'The linked media could not be reached.',
+ import_size_unavailable: 'The website did not provide the media size.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts
index 03587b0..e39ae62 100644
--- a/frontend/src/types/media.ts
+++ b/frontend/src/types/media.ts
@@ -8,6 +8,44 @@ export type PhoneMedia = {
url: string
}
+export type MediaImportSource = {
+ id: string
+ label: string
+ mediaTypes: MediaType[]
+}
+
+export type MediaImportSources = {
+ maxSelection: number
+ sources: MediaImportSource[]
+}
+
+export type ExternalMedia = {
+ externalId: string
+ filename: string
+ imported: boolean
+ mediaType: MediaType
+ size: number
+ sourceId: string
+ url: string
+}
+
+export type MediaImportPage = {
+ hasMore: boolean
+ items: ExternalMedia[]
+ page: number
+ total: number
+}
+
+export type MediaImportFailure = {
+ error: string
+ externalId: string
+}
+
+export type MediaImportResult = {
+ failed: MediaImportFailure[]
+ imported: PhoneMedia[]
+}
+
export type UploadReady = {
captureToken: string
correlationId: string
diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts
index d444aec..c2fd8de 100644
--- a/frontend/src/utils/media.test.ts
+++ b/frontend/src/utils/media.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
filterMedia,
+ formatMediaSize,
formatRecordingDuration,
hasNextMediaPage,
mediaErrorKey,
@@ -44,8 +45,19 @@ describe('media utilities', () => {
expect(formatRecordingDuration(3_725_000)).toBe('62:05')
})
+ it('formats imported media sizes with the active locale', () => {
+ expect(formatMediaSize(512, 'en')).toBe('512 B')
+ expect(formatMediaSize(1_572_864, 'en')).toBe('1.5 MB')
+ })
+
it('maps unknown server failures to the localized default', () => {
expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout')
+ expect(mediaErrorKey('import_media_too_large')).toBe(
+ 'import_media_too_large',
+ )
+ expect(mediaErrorKey('import_url_not_allowed')).toBe(
+ 'import_url_not_allowed',
+ )
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts
index 1bc21e3..d38bca8 100644
--- a/frontend/src/utils/media.ts
+++ b/frontend/src/utils/media.ts
@@ -37,14 +37,40 @@ export function formatRecordingDuration(elapsedMs: number): string {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+export function formatMediaSize(bytes: number, locale: string): string {
+ const safeBytes = Math.max(0, bytes)
+ if (safeBytes < 1024) return `${safeBytes} B`
+ const units = ['KB', 'MB', 'GB']
+ let value = safeBytes / 1024
+ let unit = units[0]
+ for (let index = 1; index < units.length && value >= 1024; index += 1) {
+ value /= 1024
+ unit = units[index]
+ }
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${unit}`
+}
+
export function mediaErrorKey(error?: string): string {
const known = new Set([
'cancelled',
'capture_failed',
'invalid_media_type',
+ 'invalid_import_request',
+ 'invalid_import_media',
+ 'invalid_import_url',
'invalid_upload',
'invalid_upload_token',
'missing_config',
+ 'import_media_not_allowed',
+ 'import_media_too_large',
+ 'import_media_unavailable',
+ 'import_provider_failed',
+ 'import_provider_unauthorized',
+ 'import_source_not_found',
+ 'import_source_unavailable',
+ 'import_url_not_allowed',
+ 'import_url_unavailable',
+ 'import_size_unavailable',
'not_found',
'operation_in_progress',
'owner_changed',
diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue
index c993d3f..9fd25ce 100644
--- a/frontend/src/views/apps/GalleryApp.vue
+++ b/frontend/src/views/apps/GalleryApp.vue
@@ -4,6 +4,9 @@ import {
kButton,
kDialog,
kLink,
+ kList,
+ kListInput,
+ kListItem,
kNavbar,
kNavbarBackLink,
kPage,
@@ -12,14 +15,30 @@ import {
kSegmentedButton,
kToast,
} from 'konsta/vue'
-import { Play, RotateCcw, Share2, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
+import {
+ ChevronRight,
+ Globe2,
+ Link2,
+ Play,
+ RotateCcw,
+ Share2,
+ Trash2,
+ ZoomIn,
+ ZoomOut,
+} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { useEasyShareStore } from '@/stores/easyshare'
-import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
+import type {
+ DeleteResult,
+ GalleryFilter,
+ MediaImportSource,
+ MediaImportSources,
+ PhoneMedia,
+} from '@/types/media'
import {
hasNextMediaPage,
MEDIA_PAGE_SIZE,
@@ -60,6 +79,12 @@ const fetching = ref(false)
const hasMore = ref(true)
const loadError = ref('')
const selected = ref(null)
+const importMode = ref<'form' | 'gallery' | 'sources'>('gallery')
+const importSources = ref([])
+const importSource = ref(null)
+const importUrl = ref('')
+const importError = ref('')
+const importing = ref(false)
const deleteDialogOpened = ref(false)
const cancelButtonColors = {
fillBgIos: 'bg-[#8e8e93] active:bg-[#7a7a7f]',
@@ -148,6 +173,71 @@ function showToast(text: string): void {
}, 3000)
}
+async function loadImportSources(): Promise {
+ const response = await nuiCall('media:import:sources')
+ if (!response.success || !response.data) return
+ importSources.value = response.data.sources
+}
+
+function selectImportSource(source: MediaImportSource): void {
+ importSource.value = source
+ importMode.value = 'form'
+ importUrl.value = ''
+ importError.value = ''
+}
+
+function openImport(): void {
+ if (importSources.value.length === 1) {
+ selectImportSource(importSources.value[0])
+ return
+ }
+ importMode.value = 'sources'
+}
+
+function closeImport(): void {
+ importMode.value = 'gallery'
+ importSource.value = null
+ importUrl.value = ''
+ importError.value = ''
+}
+
+function backFromImportForm(): void {
+ if (importSources.value.length > 1) {
+ importMode.value = 'sources'
+ importSource.value = null
+ importUrl.value = ''
+ importError.value = ''
+ return
+ }
+ closeImport()
+}
+
+function updateImportUrl(event: Event): void {
+ importUrl.value = (event.target as HTMLInputElement).value
+ importError.value = ''
+}
+
+async function commitUrlImport(): Promise {
+ const url = importUrl.value.trim()
+ if (!importSource.value || !url || importing.value) return
+ importing.value = true
+ importError.value = ''
+ const response = await nuiCall('media:import:url', {
+ sourceId: importSource.value.id,
+ url,
+ })
+ importing.value = false
+ if (!response.success || !response.data) {
+ importError.value = phone.t(
+ `Apps.photos.errors.${mediaErrorKey(response.error)}`,
+ )
+ return
+ }
+ media.value = mergeMedia(media.value, [response.data])
+ closeImport()
+ showToast(phone.t('Apps.photos.import.linkCompleted'))
+}
+
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
dateStyle: 'medium',
@@ -421,6 +511,7 @@ watch(hasMore, () => void nextTick().then(observeMore))
onMounted(() => {
window.addEventListener('message', onMessage)
void loadGallery()
+ void loadImportSources()
})
onBeforeUnmount(() => {
@@ -434,12 +525,105 @@ onBeforeUnmount(() => {
+
+
+
+
+
+
+ {{ phone.t('Apps.photos.import.chooseSource') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
{
+
+
+
+ {{ phone.t('Apps.photos.import.action') }}
+
+
+
@@ -708,6 +903,54 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow: hidden;
}
+.gallery-import-page {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.gallery-import-intro {
+ margin-bottom: 0;
+ color: #8e8e93;
+}
+.gallery-import-form {
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 48px 16px 24px;
+ text-align: center;
+}
+.gallery-import-form-icon {
+ width: 72px;
+ height: 72px;
+ display: grid;
+ place-items: center;
+ border-radius: 22px;
+ background: #0a84ff;
+ color: #fff;
+ box-shadow: 0 10px 28px #0a84ff4d;
+}
+.gallery-import-form h2 {
+ margin: 20px 0 7px;
+ font-size: 21px;
+ font-weight: 700;
+}
+.gallery-import-form p {
+ max-width: 280px;
+ margin: 0;
+ color: #8e8e93;
+ font-size: 13px;
+ line-height: 1.45;
+}
+.gallery-import-url-list {
+ width: 100%;
+ margin-top: 26px;
+}
+.gallery-import-submit {
+ width: calc(100% - 32px);
+ margin-top: 14px;
+}
.gallery-content {
min-height: 0;
flex: 1;
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index e4b0a3f..4ff3382 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -2336,7 +2336,6 @@ let mockMedia = [
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
},
]
-
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
let weazelNewsSequence = 8
let weazelNewsArticles = [
@@ -2524,6 +2523,52 @@ function pageWeazelNewsArticles(items, data) {
}
}
+const mockImportSources = [
+ {
+ id: 'media_archive',
+ label: 'Media Archive',
+ mediaTypes: ['photo', 'video'],
+ },
+ { id: 'event_cdn', label: 'Event CDN', mediaTypes: ['photo'] },
+]
+const mockImportMedia = [
+ {
+ externalId: 'archive-photo-1',
+ filename: 'Vespucci Sunset.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 2_481_152,
+ sourceId: 'media_archive',
+ url: 'https://picsum.photos/seed/sky-import-1/900/1200',
+ },
+ {
+ externalId: 'archive-photo-2',
+ filename: 'Downtown Meet.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 3_114_205,
+ sourceId: 'media_archive',
+ url: 'https://picsum.photos/seed/sky-import-2/1200/900',
+ },
+ {
+ externalId: 'archive-video-1',
+ filename: 'Flower Clip.mp4',
+ imported: false,
+ mediaType: 'video',
+ size: 8_241_152,
+ sourceId: 'media_archive',
+ url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
+ },
+ {
+ externalId: 'event-photo-1',
+ filename: 'Opening Night.jpg',
+ imported: false,
+ mediaType: 'photo',
+ size: 1_824_331,
+ sourceId: 'event_cdn',
+ url: 'https://picsum.photos/seed/sky-event-1/900/1200',
+ },
+]
const marketplaceInquiries = [
{
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
@@ -7880,6 +7925,79 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: media })
return
}
+ if (endpoint === 'media:import:sources') {
+ response.json({
+ success: true,
+ data: { maxSelection: 10, sources: mockImportSources },
+ })
+ return
+ }
+ if (endpoint === 'media:import:list') {
+ const page = Math.max(1, Number(request.body.page) || 1)
+ const limit = 30
+ const filtered = mockImportMedia.filter(
+ (item) =>
+ item.sourceId === request.body.sourceId &&
+ item.mediaType === request.body.mediaType,
+ )
+ const offset = (page - 1) * limit
+ response.json({
+ success: true,
+ data: {
+ hasMore: offset + limit < filtered.length,
+ items: filtered.slice(offset, offset + limit),
+ page,
+ total: filtered.length,
+ },
+ })
+ return
+ }
+ if (endpoint === 'media:import:commit') {
+ const externalIds = Array.isArray(request.body.externalIds)
+ ? request.body.externalIds
+ : []
+ const imported = []
+ const failed = []
+ for (const externalId of externalIds) {
+ const item = mockImportMedia.find(
+ (candidate) =>
+ candidate.externalId === externalId &&
+ candidate.sourceId === request.body.sourceId,
+ )
+ if (!item) {
+ failed.push({ error: 'import_media_unavailable', externalId })
+ continue
+ }
+ item.imported = true
+ const media = {
+ createdAt: Date.now(),
+ id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
+ mediaType: item.mediaType,
+ url: item.url,
+ }
+ mockMedia.unshift(media)
+ imported.push(media)
+ }
+ response.json({ success: true, data: { failed, imported } })
+ return
+ }
+ if (endpoint === 'media:import:url') {
+ const url = String(request.body.url || '').trim()
+ if (!url.startsWith('https://')) {
+ response.json({ success: false, error: 'invalid_import_url' })
+ return
+ }
+ const mediaType = /\.(mp4|webm)(?:[?#]|$)/i.test(url) ? 'video' : 'photo'
+ const media = {
+ createdAt: Date.now(),
+ id: Math.max(0, ...mockMedia.map((entry) => Number(entry.id) || 0)) + 1,
+ mediaType,
+ url,
+ }
+ mockMedia.unshift(media)
+ response.json({ success: true, data: media })
+ return
+ }
if (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' })
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 9db7931..d623dc5 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -1557,10 +1557,26 @@ Locales["en"] = {
deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.",
zoomIn = "Zoom in", zoomOut = "Zoom out", resetZoom = "Reset zoom",
filters = { all = "All", photos = "Photos", videos = "Videos" },
+ import = {
+ action = "Import", title = "Import", chooseSource = "Choose a website",
+ linkTitle = "Import from Link", linkBody = "Paste a direct link to an image or video from this website.",
+ linkLabel = "Image or video link", linkPlaceholder = "https://...", linkCompleted = "Media imported.",
+ loading = "Loading media...", emptyTitle = "No Media",
+ emptyBody = "This website has no media of this type.", loadMore = "Load More",
+ alreadyImported = "Imported", failed = "Failed", completed = "Imported {imported} media.",
+ partial = "Imported {imported} of {total} media.",
+ },
errors = {
cancelled = "The media action was cancelled.", capture_failed = "Unable to capture the game view.",
+ invalid_import_request = "The import request is invalid.", invalid_import_media = "This media item cannot be imported.",
invalid_media_type = "The media type is invalid.", invalid_upload = "The upload could not be verified.",
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Gallery uploads are not configured.",
+ import_media_not_allowed = "This media item is not allowed.", import_media_too_large = "This media item is too large.",
+ import_media_unavailable = "This media item is no longer available.", import_provider_failed = "The website could not load its media.",
+ import_provider_unauthorized = "The website credentials are invalid.", import_source_not_found = "This website is not registered.",
+ import_source_unavailable = "This website is temporarily unavailable.",
+ invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
+ import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed.",
operation_in_progress = "Another media operation is already in progress.",
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Gallery request failed.",
diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua
index 0abfc1a..504570b 100644
--- a/sky_phone/config/media.lua
+++ b/sky_phone/config/media.lua
@@ -10,6 +10,48 @@ Config.Media = {
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
},
+ Import = {
+ Enabled = true,
+ PageSize = 30,
+ MaxSelection = 10,
+ MaxPhotoBytes = 15 * 1024 * 1024,
+ MaxVideoBytes = 150 * 1024 * 1024,
+ RevalidateAfterSeconds = 3600,
+ ListActionsPerMinute = 60,
+ ImportActionsPerMinute = 20,
+ CandidateTtlSeconds = 300,
+ ManifestCacheSeconds = 30,
+ ManifestMaxBytes = 2 * 1024 * 1024,
+ ManifestMaxItems = 5000,
+ Websites = {
+ {
+ Id = "fivemanage",
+ Label = "FiveManage",
+ Enabled = true,
+ Adapter = "fivemanage",
+ Path = "sky_phone/imports",
+ MediaTypes = { "photo", "video" },
+ -- Direct links entered in Gallery must use one of these hosts or a subdomain.
+ AllowedMediaHosts = { "fivemanage.com" },
+ },
+ --[[
+ {
+ Id = "city_media",
+ Label = "City Media",
+ Enabled = true,
+ Adapter = "manifest",
+ ManifestUrl = "https://media.example.com/sky-phone/media.json",
+ MediaTypes = { "photo", "video" },
+ AllowedMediaHosts = { "media.example.com", "cdn.example.com" },
+ Auth = {
+ Type = "bearer",
+ TokenConvar = "sky_phone_city_media_token",
+ },
+ RequiredAce = "sky_phone.import.city_media",
+ },
+ ]]
+ },
+ },
Photo = {
Encoding = "jpg",
Quality = 0.95,
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index 76b7f45..7cba02f 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -71,6 +71,9 @@ server_scripts {
'source/server/sim.lua',
'source/server/payphones.lua',
'source/server/calls.lua',
+ 'source/server/media_import.lua',
+ 'source/server/media_import/fivemanage.lua',
+ 'source/server/media_import/manifest.lua',
'source/server/media.lua',
'source/server/weazel_news.lua',
'source/server/messages.lua',
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 8f8d109..f9263d0 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -265,6 +265,10 @@ local server_callbacks = {
"flare:send",
"gallery:list",
"media:config",
+ "media:import:sources",
+ "media:import:list",
+ "media:import:commit",
+ "media:import:url",
}
local function get_locale()
@@ -637,6 +641,15 @@ for _, callback_name in ipairs(server_callbacks) do
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
+ if callback_name:match("^media:import:") and (not result or not result.success) then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] NUI callback '%s' failed: %s.",
+ callback_name,
+ tostring(result and result.error or "no server response"),
+ { always = true }
+ )
+ end
if type(result) == "table" then
cb(result)
return
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index d15efff..d479688 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -378,6 +378,14 @@ local schema = {
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
{ name = "mime_type", type = "VARCHAR(120) NULL" },
+ { name = "origin", type = "ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload'" },
+ {
+ name = "source_id",
+ type = "VARCHAR(64) NULL",
+ characterSet = "ascii",
+ collation = "ascii_bin",
+ },
+ { name = "verified_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
@@ -2603,4 +2611,16 @@ Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_device_contact", "(`device_imei`, `contact_id`)", { unique = true })
+Bridge.Database.EnsureIndex(
+ "sky_phone_media",
+ "uniq_sky_phone_media_account_source",
+ "(`account_id`, `source_id`, `remote_id`, `origin`)",
+ { unique = true }
+)
+Bridge.Database.EnsureIndex(
+ "sky_phone_media",
+ "uniq_sky_phone_media_device_source",
+ "(`device_imei`, `source_id`, `remote_id`, `origin`)",
+ { unique = true }
+)
Bridge.Database.CompleteMigration("sky_phone")
diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua
index b7418da..f6898f0 100644
--- a/sky_phone/source/server/media.lua
+++ b/sky_phone/source/server/media.lua
@@ -1,5 +1,6 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneMedia = {}
+SkyPhoneMediaImport.Initialize()
local pending_uploads = {}
local pending_deletes = {}
@@ -76,7 +77,7 @@ local function request_presigned_url()
if not api_configured() then
return nil, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET",
@@ -99,9 +100,12 @@ local function get_remote_file(remote_id)
if not api_configured() then
return nil, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
- ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
+ ("%s/%s"):format(
+ tostring(config.BaseUrl):gsub("/+$", ""),
+ SkyPhoneMediaImport.UrlEncode(remote_id)
+ ),
"GET",
"",
{ ["Authorization"] = media_api_key() },
@@ -114,9 +118,12 @@ local function delete_remote_file(remote_id)
if not api_configured() then
return false, "missing_config"
end
- local config = media_config()
+ local config = Config.Media.FiveManage
local response = http_request(
- ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
+ ("%s/%s"):format(
+ tostring(config.BaseUrl):gsub("/+$", ""),
+ SkyPhoneMediaImport.UrlEncode(remote_id)
+ ),
"DELETE",
"",
{ ["Authorization"] = media_api_key() },
@@ -171,7 +178,9 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
params[#params + 1] = value
end
local rows = Bridge.Database.Query(([[
- SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
+ SELECT `url`, `media_type`, `mime_type`, `origin`, `source_id`, `remote_id`,
+ UNIX_TIMESTAMP(`verified_at`) AS `verified_at`
+ FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -190,6 +199,34 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
)
return nil, "invalid_attachment"
end
+ if media.origin == "website_import" then
+ local verified_at = tonumber(media.verified_at) or 0
+ local revalidate_after = math.max(
+ 1,
+ math.floor(tonumber(Config.Media.Import.RevalidateAfterSeconds) or 3600)
+ )
+ if os.time() - verified_at >= revalidate_after then
+ local refreshed, refresh_error
+ if type(media.remote_id) == "string" and media.remote_id:sub(1, 4) == "url:" then
+ refreshed, refresh_error = SkyPhoneMediaImport.ResolveUrl(media.source_id, media.url)
+ else
+ refreshed, refresh_error = SkyPhoneMediaImport.Resolve(media.source_id, media.remote_id)
+ end
+ if not refreshed then
+ return nil, refresh_error
+ end
+ if refreshed.mediaType ~= media_type then
+ return nil, "import_media_not_allowed"
+ end
+ Bridge.Database.Query([[
+ UPDATE `sky_phone_media`
+ SET `url` = ?, `mime_type` = ?, `verified_at` = CURRENT_TIMESTAMP
+ WHERE `id` = ? AND `origin` = 'website_import'
+ ]], { refreshed.url, refreshed.mimeType, id })
+ media.url = refreshed.url
+ media.mime_type = refreshed.mimeType
+ end
+ end
return media.url, nil, media.mime_type
end
@@ -499,7 +536,7 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
photo = Config.Media.Photo,
presignedUrl = presigned_url,
requestId = request_id,
- uploadTimeoutMs = media_config().UploadTimeoutMs,
+ uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
video = Config.Media.Video,
})
end)
@@ -604,7 +641,7 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
query_params[#query_params + 1] = value
end
local rows = Bridge.Database.Query(([[
- SELECT `id`, `remote_id` FROM `sky_phone_media`
+ SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
WHERE `id` = ? AND %s LIMIT 1
]]):format(condition), query_params)
local row = rows[1]
@@ -612,32 +649,35 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
delete_result(src, correlation_id, false, "not_found", media_id)
return
end
- if pending_deletes[row.remote_id] then
+ local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id)
+ if pending_deletes[delete_key] then
delete_result(src, correlation_id, false, "operation_in_progress", media_id)
return
end
- pending_deletes[row.remote_id] = src
- local references = Bridge.Database.Query(
- "SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
- { row.remote_id }
- )
- if (tonumber(references[1] and references[1].count) or 0) <= 1 then
- local deleted, delete_error = delete_remote_file(row.remote_id)
- if not deleted then
- pending_deletes[row.remote_id] = nil
- delete_result(src, correlation_id, false, delete_error, media_id)
- return
+ pending_deletes[delete_key] = src
+ if row.origin == "phone_upload" then
+ local references = Bridge.Database.Query(
+ "SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
+ { row.remote_id }
+ )
+ if (tonumber(references[1] and references[1].count) or 0) <= 1 then
+ local deleted, delete_error = delete_remote_file(row.remote_id)
+ if not deleted then
+ pending_deletes[delete_key] = nil
+ delete_result(src, correlation_id, false, delete_error, media_id)
+ return
+ end
end
end
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
- pending_deletes[row.remote_id] = nil
+ pending_deletes[delete_key] = nil
delete_result(src, correlation_id, true, nil, media_id)
end)
function SkyPhoneMedia.GetDeviceRemoteIds(imei)
local rows = Bridge.Database.Query([[
SELECT `id`, `remote_id` FROM `sky_phone_media`
- WHERE `account_id` IS NULL AND `device_imei` = ?
+ WHERE `account_id` IS NULL AND `device_imei` = ? AND `origin` = 'phone_upload'
]], { imei })
return rows
end
@@ -678,7 +718,7 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
- print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7")
+ print(("^3[sky_phone] Camera uploads and FiveManage imports are disabled until the %s server convar is set.^7")
:format(tostring(media_config().ApiKeyConvar)))
end
end)
diff --git a/sky_phone/source/server/media_import.lua b/sky_phone/source/server/media_import.lua
new file mode 100644
index 0000000..d422847
--- /dev/null
+++ b/sky_phone/source/server/media_import.lua
@@ -0,0 +1,746 @@
+SkyPhoneMediaImport = {}
+
+local adapters = {}
+local websites = {}
+local import_candidates = {}
+local initialized = false
+local media_types_by_mime = {
+ ["image/gif"] = "photo",
+ ["image/jpeg"] = "photo",
+ ["image/png"] = "photo",
+ ["image/webp"] = "photo",
+ ["video/mp4"] = "video",
+ ["video/quicktime"] = "video",
+ ["video/webm"] = "video",
+}
+
+local function session_owner(source)
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return nil, error_response
+ end
+
+ local device = SkyPhone.LoadDevice(session.imei)
+ if not device then
+ return nil, { success = false, error = "device_not_found" }
+ end
+
+ return {
+ account_id = device.account_id and tonumber(device.account_id) or nil,
+ imei = session.imei,
+ }
+end
+
+local function owner_condition(owner)
+ if owner.account_id then
+ return "`account_id` = ?", { owner.account_id }
+ end
+
+ return "`account_id` IS NULL AND `device_imei` = ?", { owner.imei }
+end
+
+local function valid_source_id(value)
+ return type(value) == "string"
+ and #value >= 1
+ and #value <= 64
+ and value:match("^[a-z0-9_%-]+$") ~= nil
+end
+
+local function valid_external_id(value)
+ return type(value) == "string"
+ and #value >= 1
+ and #value <= 128
+ and value:match("^[%w_.:%-]+$") ~= nil
+end
+
+local function website_accessible(source, website)
+ return not website.RequiredAce or website.RequiredAce == "" or IsPlayerAceAllowed(source, website.RequiredAce)
+end
+
+local function media_type_set(values)
+ local allowed = {}
+ if type(values) ~= "table" then
+ return allowed
+ end
+
+ for _, value in ipairs(values) do
+ if value == "photo" or value == "video" then
+ allowed[value] = true
+ end
+ end
+ return allowed
+end
+
+local function url_host(value)
+ if type(value) ~= "string" or #value > Config.Media.UrlMaxLength or value:find("%c") then
+ return nil
+ end
+
+ local authority = value:match("^https://([^/%?#]+)")
+ if not authority or authority:find("@", 1, true) then
+ return nil
+ end
+
+ local host = authority:match("^([^:]+)")
+ return host and host:lower() or nil
+end
+
+function SkyPhoneMediaImport.ResponseHeader(headers, name)
+ if type(headers) ~= "table" then
+ return nil
+ end
+ local requested_name = name:lower()
+ for header_name, value in pairs(headers) do
+ if type(header_name) == "string" and header_name:lower() == requested_name then
+ if type(value) == "table" then
+ return value[1]
+ end
+ return value
+ end
+ end
+ return nil
+end
+
+local function allowed_host(website, host)
+ for _, configured_host in ipairs(website.AllowedMediaHosts) do
+ local candidate = type(configured_host) == "string" and configured_host:lower():gsub("^%.", "") or ""
+ if candidate ~= "" and (host == candidate or host:sub(-#candidate - 1) == "." .. candidate) then
+ return true
+ end
+ end
+ return false
+end
+
+local function normalize_media(website, item)
+ if type(item) ~= "table" or not valid_external_id(item.externalId) then
+ return nil, "invalid_import_media"
+ end
+
+ local media_type = item.mediaType
+ if not website._media_types[media_type] then
+ return nil, "import_media_not_allowed"
+ end
+
+ local mime_type = type(item.mimeType) == "string"
+ and item.mimeType:lower():match("^%s*([^;%s]+)") or nil
+ if not mime_type or media_types_by_mime[mime_type] ~= media_type then
+ local path = type(item.url) == "string" and item.url:match("^https://[^/]+(/[^?#]*)") or nil
+ local extension = path and path:match("%.([%w]+)$") or nil
+ local mime_by_extension = {
+ gif = "image/gif",
+ jpeg = "image/jpeg",
+ jpg = "image/jpeg",
+ mov = "video/quicktime",
+ mp4 = "video/mp4",
+ png = "image/png",
+ webm = "video/webm",
+ webp = "image/webp",
+ }
+ mime_type = extension and mime_by_extension[extension:lower()] or nil
+ end
+
+ local size = tonumber(item.size)
+ if not size or size <= 0 or size ~= math.floor(size) then
+ return nil, "invalid_import_media"
+ end
+
+ local size_limit = media_type == "photo"
+ and tonumber(Config.Media.Import.MaxPhotoBytes)
+ or tonumber(Config.Media.Import.MaxVideoBytes)
+ if not size_limit or size > size_limit then
+ return nil, "import_media_too_large"
+ end
+
+ local host = url_host(item.url)
+ if not host or not allowed_host(website, host) then
+ return nil, "import_media_not_allowed"
+ end
+
+ local filename = type(item.filename) == "string" and item.filename:match("^%s*(.-)%s*$") or ""
+ if filename == "" then
+ filename = item.externalId
+ elseif #filename > 160 then
+ filename = filename:sub(1, 160)
+ end
+
+ return {
+ externalId = item.externalId,
+ filename = filename,
+ mediaType = media_type,
+ mimeType = mime_type,
+ size = size,
+ sourceId = website.Id,
+ url = item.url,
+ }
+end
+
+local function remember_candidate(source, media)
+ local expires_at = os.time() + math.max(
+ 30,
+ math.floor(tonumber(Config.Media.Import.CandidateTtlSeconds) or 300)
+ )
+ import_candidates[source] = import_candidates[source] or {}
+ import_candidates[source][media.sourceId] = import_candidates[source][media.sourceId] or {}
+ import_candidates[source][media.sourceId][media.externalId] = expires_at
+end
+
+local function candidate_allowed(source, source_id, external_id)
+ local by_source = import_candidates[source] and import_candidates[source][source_id]
+ local expires_at = by_source and by_source[external_id]
+ if not expires_at or expires_at < os.time() then
+ if by_source then
+ by_source[external_id] = nil
+ end
+ return false
+ end
+ return true
+end
+
+local function validate_website(definition)
+ if type(definition) ~= "table"
+ or definition.Enabled == false
+ or not valid_source_id(definition.Id)
+ or type(definition.Label) ~= "string"
+ or definition.Label == ""
+ or #definition.Label > 64
+ or type(definition.Adapter) ~= "string"
+ then
+ return nil, "invalid_definition"
+ end
+
+ local adapter = adapters[definition.Adapter]
+ if not adapter then
+ return nil, "unknown_adapter"
+ end
+
+ if type(definition.AllowedMediaHosts) ~= "table" or #definition.AllowedMediaHosts < 1 then
+ return nil, "missing_allowed_hosts"
+ end
+
+ local allowed_media_types = media_type_set(definition.MediaTypes)
+ if not allowed_media_types.photo and not allowed_media_types.video then
+ return nil, "missing_media_types"
+ end
+
+ if definition.RequiredAce ~= nil and type(definition.RequiredAce) ~= "string" then
+ return nil, "invalid_required_ace"
+ end
+
+ definition._adapter = adapter
+ definition._media_types = allowed_media_types
+ local valid, validation_error = adapter.Validate(definition)
+ if not valid then
+ return nil, validation_error
+ end
+
+ return definition
+end
+
+local function build_registry()
+ websites = {}
+ local config = Config.Media.Import
+ if not config.Enabled then
+ return
+ end
+
+ for index, definition in ipairs(config.Websites or {}) do
+ local website, website_error = validate_website(definition)
+ if website then
+ if websites[website.Id] then
+ error(("[sky_phone] Duplicate media import website id '%s'."):format(website.Id))
+ end
+ websites[website.Id] = website
+ else
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import website at index %s was disabled: %s.",
+ tostring(index),
+ tostring(website_error)
+ )
+ end
+ end
+end
+
+local function find_imported(owner, source_id, items)
+ if #items == 0 then
+ return
+ end
+
+ local condition, params = owner_condition(owner)
+ params[#params + 1] = source_id
+ local placeholders = {}
+ for index, item in ipairs(items) do
+ placeholders[index] = "?"
+ params[#params + 1] = item.externalId
+ end
+
+ local rows = Bridge.Database.Query(([[
+ SELECT `remote_id` FROM `sky_phone_media`
+ WHERE %s AND `origin` = 'website_import' AND `source_id` = ?
+ AND `remote_id` IN (%s)
+ ]]):format(condition, table.concat(placeholders, ", ")), params)
+ local imported = {}
+ for _, row in ipairs(rows) do
+ imported[row.remote_id] = true
+ end
+ for _, item in ipairs(items) do
+ item.imported = imported[item.externalId] or false
+ end
+end
+
+local function select_owned_import(owner, source_id, remote_id)
+ local condition, params = owner_condition(owner)
+ params[#params + 1] = source_id
+ params[#params + 1] = remote_id
+ local rows = Bridge.Database.Query(([[
+ SELECT `id`, `url`, `media_type` AS `mediaType`, `mime_type` AS `mimeType`,
+ UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt`
+ FROM `sky_phone_media`
+ WHERE %s AND `origin` = 'website_import'
+ AND `source_id` = ? AND `remote_id` = ?
+ LIMIT 1
+ ]]):format(condition), params)
+ local row = rows[1]
+ if row then
+ row.id = tonumber(row.id)
+ row.createdAt = tonumber(row.createdAt) or 0
+ end
+ return row
+end
+
+local function store_import(owner, media)
+ local existing = select_owned_import(owner, media.sourceId, media.externalId)
+ if existing then
+ Bridge.Database.Query([[
+ UPDATE `sky_phone_media`
+ SET `url` = ?, `media_type` = ?, `mime_type` = ?, `verified_at` = CURRENT_TIMESTAMP
+ WHERE `id` = ?
+ ]], { media.url, media.mediaType, media.mimeType, existing.id })
+ existing.url = media.url
+ existing.mediaType = media.mediaType
+ existing.mimeType = media.mimeType
+ return existing
+ end
+
+ local result
+ if owner.account_id then
+ result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_media`
+ (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `origin`, `source_id`, `verified_at`)
+ VALUES (?, NULL, ?, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
+ ]], { owner.account_id, media.url, media.externalId, media.mediaType, media.mimeType, media.sourceId })
+ else
+ result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_media`
+ (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `origin`, `source_id`, `verified_at`)
+ VALUES (NULL, ?, ?, ?, ?, ?, 'website_import', ?, CURRENT_TIMESTAMP)
+ ]], { owner.imei, media.url, media.externalId, media.mediaType, media.mimeType, media.sourceId })
+ end
+
+ local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
+ if media_id and media_id < 1 then
+ media_id = nil
+ end
+ if not media_id then
+ local concurrent = select_owned_import(owner, media.sourceId, media.externalId)
+ if concurrent then
+ return concurrent
+ end
+ return nil, "request_failed"
+ end
+
+ return {
+ createdAt = os.time() * 1000,
+ id = media_id,
+ mediaType = media.mediaType,
+ url = media.url,
+ }
+end
+
+function SkyPhoneMediaImport.RegisterAdapter(name, adapter)
+ assert(type(name) == "string" and name ~= "", "Media import adapter name must be a string")
+ assert(type(adapter) == "table", "Media import adapter must be a table")
+ assert(type(adapter.Validate) == "function", "Media import adapter requires Validate")
+ assert(type(adapter.List) == "function", "Media import adapter requires List")
+ assert(type(adapter.Resolve) == "function", "Media import adapter requires Resolve")
+ assert(not adapters[name], ("Media import adapter '%s' is already registered"):format(name))
+ adapters[name] = adapter
+end
+
+function SkyPhoneMediaImport.HttpRequest(url, headers, timeout_ms, method)
+ local request = promise.new()
+ local settled = false
+ local request_method = method or "GET"
+ local request_host = url_host(url) or "invalid-host"
+ PerformHttpRequest(url, function(status, response_body, response_headers, error_data)
+ if settled then
+ return
+ end
+ settled = true
+ local response_status = tonumber(status) or 0
+ if response_status == 0 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import HTTP %s request to '%s' failed: %s.",
+ request_method,
+ request_host,
+ tostring(error_data or "unknown transport error"),
+ { always = true }
+ )
+ elseif response_status >= 400 then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] Media import HTTP %s request to '%s' returned status %s.",
+ request_method,
+ request_host,
+ tostring(response_status)
+ )
+ end
+ request:resolve({
+ body = response_body or "",
+ error = error_data,
+ headers = response_headers or {},
+ status = response_status,
+ })
+ end, request_method, "", headers or {}, { followLocation = false })
+ SetTimeout(timeout_ms, function()
+ if settled then
+ return
+ end
+ settled = true
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media import HTTP %s request to '%s' timed out after %s ms.",
+ request_method,
+ request_host,
+ tostring(timeout_ms),
+ { always = true }
+ )
+ request:resolve({ body = "", error = "request timeout", headers = {}, status = 0 })
+ end)
+ return Citizen.Await(request)
+end
+
+function SkyPhoneMediaImport.ResolveUrl(source_id, url)
+ if not initialized or not valid_source_id(source_id) or type(url) ~= "string" then
+ return nil, "invalid_import_url"
+ end
+
+ local website = websites[source_id]
+ local trimmed_url = url:match("^%s*(.-)%s*$")
+ local host = website and url_host(trimmed_url) or nil
+ if not website or not host or not allowed_host(website, host) then
+ return nil, "import_url_not_allowed"
+ end
+
+ if type(website._adapter.ResolveUrl) == "function" then
+ local item, resolve_error = website._adapter.ResolveUrl(website, trimmed_url)
+ if not item then
+ return nil, resolve_error
+ end
+ return normalize_media(website, item)
+ end
+
+ local response = SkyPhoneMediaImport.HttpRequest(
+ trimmed_url,
+ {},
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000,
+ "HEAD"
+ )
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status < 200 or response.status >= 300 then
+ return nil, "import_url_unavailable"
+ end
+
+ local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ content_type = type(content_type) == "string" and content_type:lower():match("^%s*([^;%s]+)") or nil
+ local media_type = content_type and media_types_by_mime[content_type] or nil
+ if not media_type or not website._media_types[media_type] then
+ return nil, "import_media_not_allowed"
+ end
+
+ local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+ if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
+ return nil, "import_size_unavailable"
+ end
+
+ local external_id = ("url:%08x%08x"):format(
+ joaat(trimmed_url) & 0xffffffff,
+ joaat("sky_phone:" .. trimmed_url) & 0xffffffff
+ )
+ local url_path = trimmed_url:match("^https://[^/]+(/[^?#]*)") or ""
+ return normalize_media(website, {
+ externalId = external_id,
+ filename = url_path:match("/([^/]+)$") or external_id,
+ mediaType = media_type,
+ mimeType = content_type,
+ size = content_length,
+ url = trimmed_url,
+ })
+end
+
+function SkyPhoneMediaImport.UrlEncode(value)
+ return tostring(value):gsub("\n", "\r\n"):gsub("([^%w%-_%.~])", function(character)
+ return ("%%%02X"):format(character:byte())
+ end)
+end
+
+function SkyPhoneMediaImport.Resolve(source_id, external_id)
+ if not initialized or not valid_source_id(source_id) or not valid_external_id(external_id) then
+ return nil, "import_source_unavailable"
+ end
+
+ local website = websites[source_id]
+ if not website then
+ return nil, "import_source_unavailable"
+ end
+
+ local item, resolve_error = website._adapter.Resolve(website, external_id)
+ if not item then
+ return nil, resolve_error
+ end
+
+ return normalize_media(website, item)
+end
+
+function SkyPhoneMediaImport.Initialize()
+ assert(not initialized, "Media import was initialized more than once")
+ build_registry()
+ initialized = true
+
+ Bridge.Callbacks.Register("sky_phone:media:import:sources", function(source)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+
+ local sources = {}
+ for _, website in pairs(websites) do
+ if website_accessible(source, website) then
+ local media_types = {}
+ if website._media_types.photo then
+ media_types[#media_types + 1] = "photo"
+ end
+ if website._media_types.video then
+ media_types[#media_types + 1] = "video"
+ end
+ sources[#sources + 1] = {
+ id = website.Id,
+ label = website.Label,
+ mediaTypes = media_types,
+ }
+ end
+ end
+ table.sort(sources, function(left, right)
+ return left.label:lower() < right.label:lower()
+ end)
+ return {
+ success = true,
+ data = {
+ maxSelection = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1)),
+ sources = sources,
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:list", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_list",
+ tonumber(Config.Media.Import.ListActionsPerMinute) or 60,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ local media_type = data.mediaType
+ local page = math.floor(tonumber(data.page) or 1)
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if not website._media_types[media_type] or page < 1 or page > 10000 then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local limit = math.max(1, math.min(math.floor(tonumber(Config.Media.Import.PageSize) or 30), 100))
+ local result, list_error = website._adapter.List(website, media_type, page, limit)
+ if not result then
+ return { success = false, error = list_error }
+ end
+ if type(result) ~= "table" or type(result.items) ~= "table" then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Import source '%s' returned an invalid list response.",
+ website.Id
+ )
+ return { success = false, error = "import_provider_failed" }
+ end
+
+ local items = {}
+ for _, item in ipairs(result.items) do
+ local normalized, normalize_error = normalize_media(website, item)
+ if normalized then
+ items[#items + 1] = normalized
+ remember_candidate(source, normalized)
+ else
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Rejected media '%s' from import source '%s': %s.",
+ tostring(item.externalId),
+ website.Id,
+ tostring(normalize_error)
+ )
+ end
+ end
+ find_imported(owner, website.Id, items)
+ return {
+ success = true,
+ data = {
+ hasMore = result.hasMore == true,
+ items = items,
+ page = page,
+ total = math.max(0, math.floor(tonumber(result.total) or #items)),
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:commit", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_commit",
+ tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if type(data.externalIds) ~= "table" then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local maximum = math.max(1, math.floor(tonumber(Config.Media.Import.MaxSelection) or 1))
+ if #data.externalIds < 1 or #data.externalIds > maximum then
+ return { success = false, error = "invalid_import_request" }
+ end
+
+ local unique_ids = {}
+ local requested_ids = {}
+ for _, external_id in ipairs(data.externalIds) do
+ if not valid_external_id(external_id) or unique_ids[external_id] then
+ return { success = false, error = "invalid_import_request" }
+ end
+ if not candidate_allowed(source, website.Id, external_id) then
+ return { success = false, error = "invalid_import_request" }
+ end
+ unique_ids[external_id] = true
+ requested_ids[#requested_ids + 1] = external_id
+ end
+
+ local imported = {}
+ local failed = {}
+ for _, external_id in ipairs(requested_ids) do
+ local item, resolve_error = website._adapter.Resolve(website, external_id)
+ local normalized, normalize_error
+ if item then
+ normalized, normalize_error = normalize_media(website, item)
+ end
+ if not normalized then
+ failed[#failed + 1] = {
+ error = resolve_error or normalize_error or "import_provider_failed",
+ externalId = external_id,
+ }
+ else
+ local stored, store_error = store_import(owner, normalized)
+ if stored then
+ imported[#imported + 1] = stored
+ else
+ failed[#failed + 1] = {
+ error = store_error or "request_failed",
+ externalId = external_id,
+ }
+ end
+ end
+ end
+
+ return {
+ success = true,
+ data = {
+ failed = failed,
+ imported = imported,
+ },
+ }
+ end)
+
+ Bridge.Callbacks.Register("sky_phone:media:import:url", function(source, data)
+ local owner, error_response = session_owner(source)
+ if not owner then
+ return error_response
+ end
+ if not SkyPhone.AllowOperation(
+ source,
+ "media_import_url",
+ tonumber(Config.Media.Import.ImportActionsPerMinute) or 20,
+ 60
+ ) then
+ return { success = false, error = "rate_limited" }
+ end
+
+ data = type(data) == "table" and data or {}
+ local website = valid_source_id(data.sourceId) and websites[data.sourceId] or nil
+ if not website or not website_accessible(source, website) then
+ return { success = false, error = "import_source_not_found" }
+ end
+ if type(data.url) ~= "string" or #data.url < 1 or #data.url > Config.Media.UrlMaxLength then
+ return { success = false, error = "invalid_import_url" }
+ end
+
+ local normalized, resolve_error = SkyPhoneMediaImport.ResolveUrl(website.Id, data.url)
+ if not normalized then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] Media URL import failed for player %s, source '%s', host '%s': %s.",
+ tostring(source),
+ website.Id,
+ tostring(url_host(data.url) or "invalid-host"),
+ tostring(resolve_error),
+ { always = true }
+ )
+ return { success = false, error = resolve_error }
+ end
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] Media URL import resolved for player %s via source '%s' as %s '%s' (%s bytes).",
+ tostring(source),
+ website.Id,
+ normalized.mediaType,
+ normalized.externalId,
+ tostring(normalized.size)
+ )
+ local stored, store_error = store_import(owner, normalized)
+ if not stored then
+ return { success = false, error = store_error or "request_failed" }
+ end
+ return { success = true, data = stored }
+ end)
+
+ AddEventHandler("playerDropped", function()
+ import_candidates[source] = nil
+ end)
+end
diff --git a/sky_phone/source/server/media_import/fivemanage.lua b/sky_phone/source/server/media_import/fivemanage.lua
new file mode 100644
index 0000000..4168dc9
--- /dev/null
+++ b/sky_phone/source/server/media_import/fivemanage.lua
@@ -0,0 +1,240 @@
+local function api_key(website)
+ local convar = website.ApiKeyConvar or Config.Media.FiveManage.ApiKeyConvar
+ if type(convar) ~= "string" or convar == "" then
+ return ""
+ end
+ return GetConvar(convar, "")
+end
+
+local function provider_error(response, not_found_error)
+ if response.status == 0 then
+ return "import_source_unavailable"
+ end
+ if response.status == 401 or response.status == 403 then
+ return "import_provider_unauthorized"
+ end
+ if response.status == 404 and not_found_error then
+ return not_found_error
+ end
+ return "import_provider_failed"
+end
+
+local function decode_response(response, not_found_error)
+ if type(response) ~= "table" or response.status < 200 or response.status >= 300 then
+ return nil, provider_error(response or { status = 0 }, not_found_error)
+ end
+
+ local success, decoded = pcall(json.decode, response.body or "")
+ if not success or type(decoded) ~= "table" then
+ return nil, "import_provider_failed"
+ end
+ return decoded
+end
+
+local function media_type(value)
+ local normalized = type(value) == "string" and value:lower() or ""
+ if normalized == "image" or normalized:find("image/", 1, true) == 1 then
+ return "photo"
+ end
+ if normalized == "video" or normalized:find("video/", 1, true) == 1 then
+ return "video"
+ end
+ return nil
+end
+
+local media_extensions = {
+ gif = true,
+ jpeg = true,
+ jpg = true,
+ mov = true,
+ mp4 = true,
+ png = true,
+ webm = true,
+ webp = true,
+}
+
+local function public_file_id(url)
+ local path = type(url) == "string" and url:match("^https://[^/%?#]+(/[^?#]*)") or nil
+ local segment = path and path:match("/([^/]+)$") or nil
+ if not segment or segment == "" or segment:find("%", 1, true) then
+ return nil
+ end
+
+ local extension = segment:match("%.([%w]+)$")
+ if extension and media_extensions[extension:lower()] then
+ segment = segment:sub(1, -#extension - 2)
+ end
+ if #segment < 1 or #segment > 128 or not segment:match("^[%w_%-]+$") then
+ return nil
+ end
+ return segment
+end
+
+local function normalize_file(file)
+ if type(file) ~= "table" then
+ return {}
+ end
+ return {
+ externalId = file.id,
+ filename = file.filename,
+ mediaType = media_type(file.type or file.mimeType),
+ mimeType = file.mimeType or file.type,
+ size = file.size,
+ url = file.url,
+ }
+end
+
+local function resolve_file(website, external_id)
+ local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ local response = SkyPhoneMediaImport.HttpRequest(
+ ("%s/%s"):format(provider_url, SkyPhoneMediaImport.UrlEncode(external_id)),
+ { ["Authorization"] = api_key(website) },
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ )
+ local decoded, response_error = decode_response(response, "import_media_unavailable")
+ if not decoded then
+ return nil, response_error
+ end
+
+ local file = type(decoded.data) == "table" and decoded.data or decoded
+ if file.id ~= external_id then
+ return nil, "invalid_import_media"
+ end
+ return normalize_file(file)
+end
+
+local function probe_public_url(website, url)
+ local timeout = tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ local response = SkyPhoneMediaImport.HttpRequest(url, {}, timeout, "HEAD")
+ local content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ local content_length = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+
+ if response.status == 0 or (response.status >= 200 and response.status < 300
+ and (not content_type or not content_length or content_length <= 0))
+ then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage HEAD probe did not provide usable metadata; trying a one-byte range request."
+ )
+ response = SkyPhoneMediaImport.HttpRequest(url, { ["Range"] = "bytes=0-0" }, timeout)
+ content_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")
+ local content_range = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-range")
+ content_length = type(content_range) == "string" and tonumber(content_range:match("/(%d+)$"))
+ or tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length"))
+ end
+
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status < 200 or response.status >= 300 then
+ Bridge.Debug(
+ "warn",
+ "[sky_phone] FiveManage public URL probe returned HTTP %s.",
+ tostring(response.status),
+ { always = true }
+ )
+ return nil, "import_url_unavailable"
+ end
+
+ local normalized_type = media_type(content_type)
+ if not normalized_type then
+ return nil, "import_media_not_allowed"
+ end
+ if not content_length or content_length <= 0 or content_length ~= math.floor(content_length) then
+ return nil, "import_size_unavailable"
+ end
+
+ local url_path = url:match("^https://[^/]+(/[^?#]*)") or ""
+ local external_id = ("url:%08x%08x"):format(
+ joaat(url) & 0xffffffff,
+ joaat("sky_phone:" .. url) & 0xffffffff
+ )
+ return {
+ externalId = external_id,
+ filename = url_path:match("/([^/]+)$") or external_id,
+ mediaType = normalized_type,
+ mimeType = type(content_type) == "string"
+ and content_type:lower():match("^%s*([^;%s]+)") or nil,
+ size = content_length,
+ url = url,
+ }
+end
+
+SkyPhoneMediaImport.RegisterAdapter("fivemanage", {
+ Validate = function(website)
+ local url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ if not url:match("^https://") or #url > Config.Media.UrlMaxLength then
+ return false, "invalid_base_url"
+ end
+ if type(website.Path) ~= "string" or website.Path == "" or #website.Path > 180 then
+ return false, "missing_import_path"
+ end
+ if api_key(website) == "" then
+ return false, "missing_api_key"
+ end
+ return true
+ end,
+
+ List = function(website, requested_type, page, limit)
+ local provider_type = requested_type == "photo" and "image" or "video"
+ local provider_url = tostring(website.BaseUrl or Config.Media.FiveManage.BaseUrl):gsub("/+$", "")
+ local url = ("%s?page=%s&limit=%s&type=%s&path=%s"):format(
+ provider_url,
+ page,
+ limit,
+ provider_type,
+ SkyPhoneMediaImport.UrlEncode(website.Path)
+ )
+ local response = SkyPhoneMediaImport.HttpRequest(
+ url,
+ { ["Authorization"] = api_key(website) },
+ tonumber(website.RequestTimeoutMs or Config.Media.FiveManage.RequestTimeoutMs) or 10000
+ )
+ local decoded, response_error = decode_response(response)
+ if not decoded then
+ return nil, response_error
+ end
+
+ local files = type(decoded.data) == "table" and decoded.data or {}
+ local items = {}
+ for _, file in ipairs(files) do
+ items[#items + 1] = normalize_file(file)
+ end
+ local pagination = type(decoded.pagination) == "table" and decoded.pagination or {}
+ local total = math.max(0, math.floor(tonumber(pagination.total) or #items))
+ local current_page = math.max(1, math.floor(tonumber(pagination.page) or page))
+ local page_limit = math.max(1, math.floor(tonumber(pagination.limit) or limit))
+ return {
+ hasMore = current_page * page_limit < total,
+ items = items,
+ total = total,
+ }
+ end,
+
+ Resolve = resolve_file,
+
+ ResolveUrl = function(website, url)
+ local external_id = public_file_id(url)
+ if external_id then
+ local file, resolve_error = resolve_file(website, external_id)
+ if file then
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage URL resolved through authenticated metadata for file '%s'.",
+ external_id
+ )
+ return file
+ end
+ if resolve_error == "import_provider_unauthorized" then
+ return nil, resolve_error
+ end
+ Bridge.Debug(
+ "debug",
+ "[sky_phone] FiveManage metadata lookup for file '%s' failed with '%s'; probing the public URL.",
+ external_id,
+ tostring(resolve_error)
+ )
+ end
+ return probe_public_url(website, url)
+ end,
+})
diff --git a/sky_phone/source/server/media_import/manifest.lua b/sky_phone/source/server/media_import/manifest.lua
new file mode 100644
index 0000000..4b04666
--- /dev/null
+++ b/sky_phone/source/server/media_import/manifest.lua
@@ -0,0 +1,153 @@
+local manifest_cache = {}
+
+local function authentication_headers(website)
+ local auth = website.Auth
+ if not auth or auth.Type == nil or auth.Type == "none" then
+ return {}
+ end
+
+ if auth.Type == "bearer" then
+ return { ["Authorization"] = "Bearer " .. GetConvar(auth.TokenConvar, "") }
+ end
+ return { [auth.Header] = GetConvar(auth.ValueConvar, "") }
+end
+
+local function validate_authentication(auth)
+ if auth == nil then
+ return true
+ end
+ if type(auth) ~= "table" then
+ return false, "invalid_auth"
+ end
+ if auth.Type == nil or auth.Type == "none" then
+ return true
+ end
+ if auth.Type == "bearer" then
+ if type(auth.TokenConvar) ~= "string" or auth.TokenConvar == ""
+ or GetConvar(auth.TokenConvar, "") == ""
+ then
+ return false, "missing_auth_convar"
+ end
+ return true
+ end
+ if auth.Type == "header" then
+ if type(auth.Header) ~= "string" or not auth.Header:match("^[%w%-]+$")
+ or type(auth.ValueConvar) ~= "string" or auth.ValueConvar == ""
+ or GetConvar(auth.ValueConvar, "") == ""
+ then
+ return false, "invalid_header_auth"
+ end
+ return true
+ end
+ return false, "unknown_auth_type"
+end
+
+local function fetch_manifest(website)
+ local now = os.time()
+ local cached = manifest_cache[website.Id]
+ if cached and cached.expires_at > now then
+ return cached.items
+ end
+
+ local response = SkyPhoneMediaImport.HttpRequest(
+ website.ManifestUrl,
+ authentication_headers(website),
+ tonumber(website.RequestTimeoutMs) or 10000
+ )
+ if response.status == 0 then
+ return nil, "import_source_unavailable"
+ end
+ if response.status == 401 or response.status == 403 then
+ return nil, "import_provider_unauthorized"
+ end
+ if response.status < 200 or response.status >= 300 then
+ return nil, "import_provider_failed"
+ end
+
+ local max_bytes = math.max(1024, math.floor(tonumber(Config.Media.Import.ManifestMaxBytes) or 2097152))
+ if #response.body > max_bytes then
+ return nil, "import_provider_failed"
+ end
+
+ local success, decoded = pcall(json.decode, response.body)
+ if not success or type(decoded) ~= "table" or tonumber(decoded.version) ~= 1
+ or type(decoded.items) ~= "table"
+ then
+ return nil, "import_provider_failed"
+ end
+
+ local maximum_items = math.max(1, math.floor(tonumber(Config.Media.Import.ManifestMaxItems) or 5000))
+ if #decoded.items > maximum_items then
+ return nil, "import_provider_failed"
+ end
+
+ local items = {}
+ for _, item in ipairs(decoded.items) do
+ if type(item) == "table" then
+ items[#items + 1] = {
+ externalId = item.id,
+ filename = item.filename,
+ mediaType = item.type,
+ mimeType = item.mimeType,
+ size = item.size,
+ url = item.url,
+ }
+ end
+ end
+ manifest_cache[website.Id] = {
+ expires_at = now + math.max(1, math.floor(tonumber(website.CacheSeconds)
+ or tonumber(Config.Media.Import.ManifestCacheSeconds) or 30)),
+ items = items,
+ }
+ return items
+end
+
+SkyPhoneMediaImport.RegisterAdapter("manifest", {
+ Validate = function(website)
+ if type(website.ManifestUrl) ~= "string"
+ or not website.ManifestUrl:match("^https://")
+ or #website.ManifestUrl > Config.Media.UrlMaxLength
+ then
+ return false, "invalid_manifest_url"
+ end
+ return validate_authentication(website.Auth)
+ end,
+
+ List = function(website, requested_type, page, limit)
+ local manifest, manifest_error = fetch_manifest(website)
+ if not manifest then
+ return nil, manifest_error
+ end
+
+ local matching = {}
+ for _, item in ipairs(manifest) do
+ if item.mediaType == requested_type then
+ matching[#matching + 1] = item
+ end
+ end
+ local first = (page - 1) * limit + 1
+ local last = math.min(#matching, first + limit - 1)
+ local items = {}
+ for index = first, last do
+ items[#items + 1] = matching[index]
+ end
+ return {
+ hasMore = last < #matching,
+ items = items,
+ total = #matching,
+ }
+ end,
+
+ Resolve = function(website, external_id)
+ local manifest, manifest_error = fetch_manifest(website)
+ if not manifest then
+ return nil, manifest_error
+ end
+ for _, item in ipairs(manifest) do
+ if item.externalId == external_id then
+ return item
+ end
+ end
+ return nil, "import_media_unavailable"
+ end,
+})
diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql
index a269887..d39dc52 100644
--- a/sky_phone/sql/install.sql
+++ b/sky_phone/sql/install.sql
@@ -153,8 +153,14 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
`url` TEXT NOT NULL,
`remote_id` VARCHAR(128) NOT NULL,
`media_type` ENUM('photo', 'video') NOT NULL,
+ `mime_type` VARCHAR(120) NULL,
+ `origin` ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload',
+ `source_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
+ `verified_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_sky_phone_media_account_source` (`account_id`, `source_id`, `remote_id`, `origin`),
+ UNIQUE KEY `uniq_sky_phone_media_device_source` (`device_imei`, `source_id`, `remote_id`, `origin`),
KEY `idx_sky_phone_media_account` (`account_id`, `created_at`, `id`),
KEY `idx_sky_phone_media_device` (`device_imei`, `created_at`, `id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
From 6d25423f45fc8c5db81223344e08086844636d72 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Thu, 13 Aug 2026 03:08:37 +0200
Subject: [PATCH 47/63] BLD - enable development-only Vue DevTools
---
frontend/package.json | 3 +-
frontend/pnpm-lock.yaml | 742 ++++++++++++++++++++++++++++++++++++++++
frontend/vite.config.ts | 7 +-
3 files changed, 748 insertions(+), 4 deletions(-)
diff --git a/frontend/package.json b/frontend/package.json
index 12fac4a..566a5a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,7 +15,6 @@
"test:browser-mocks": "node testserver/smoke.cjs"
},
"dependencies": {
- "emoji-picker-element-data": "^1.8.0",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-placeholder": "^3.29.2",
"@tiptap/markdown": "^3.29.2",
@@ -23,6 +22,7 @@
"@tiptap/starter-kit": "^3.29.2",
"@tiptap/vue-3": "^3.29.2",
"dompurify": "^3.4.13",
+ "emoji-picker-element-data": "^1.8.0",
"fix-webm-duration": "^1.0.6",
"konsta": "~5.2.0",
"lucide-vue-next": "^0.525.0",
@@ -47,6 +47,7 @@
"tailwindcss": "^4.0.0",
"typescript": "~5.8.0",
"vite": "^7.0.0",
+ "vite-plugin-vue-devtools": "^8.2.1",
"vitest": "^3.2.4",
"vue-tsc": "^2.2.10"
}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1f4dd36..5588678 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -99,6 +99,9 @@ importers:
vite:
specifier: ^7.0.0
version: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+ vite-plugin-vue-devtools:
+ specifier: ^8.2.1
+ version: 8.2.1(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.40(typescript@5.8.3))
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
@@ -108,6 +111,72 @@ importers:
packages:
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-annotate-as-pure@7.29.7':
+ resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-create-class-features-plugin@7.29.7':
+ resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-member-expression-to-functions@7.29.7':
+ resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-optimise-call-expression@7.29.7':
+ resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-replace-supers@7.29.7':
+ resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
+ resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -116,11 +185,68 @@ packages:
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/plugin-proposal-decorators@7.29.7':
+ resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-decorators@7.29.7':
+ resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-import-attributes@7.29.7':
+ resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-import-meta@7.10.4':
+ resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-jsx@7.29.7':
+ resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-typescript@7.29.7':
+ resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-typescript@7.29.7':
+ resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.8':
+ resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
@@ -369,6 +495,7 @@ packages:
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -386,6 +513,9 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -423,66 +553,79 @@ packages:
resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.4':
resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.4':
resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.4':
resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.4':
resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.4':
resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.4':
resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.4':
resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.4':
resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.4':
resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.4':
resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.4':
resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.4':
resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.4':
resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
@@ -552,24 +695,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
@@ -891,6 +1038,22 @@ packages:
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+ '@vue/babel-helper-vue-transform-on@1.5.0':
+ resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
+
+ '@vue/babel-plugin-jsx@1.5.0':
+ resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+
+ '@vue/babel-plugin-resolve-type@1.5.0':
+ resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@vue/compiler-core@3.5.40':
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
@@ -912,12 +1075,23 @@ packages:
'@vue/devtools-api@7.7.10':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
+ '@vue/devtools-core@8.2.1':
+ resolution: {integrity: sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA==}
+ peerDependencies:
+ vue: ^3.0.0
+
'@vue/devtools-kit@7.7.10':
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
+ '@vue/devtools-kit@8.2.1':
+ resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==}
+
'@vue/devtools-shared@7.7.10':
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
+ '@vue/devtools-shared@8.2.1':
+ resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
+
'@vue/eslint-config-prettier@10.2.0':
resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==}
peerDependencies:
@@ -998,6 +1172,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansis@4.3.1:
+ resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
+ engines: {node: '>=14'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1012,9 +1190,17 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ baseline-browser-mapping@2.11.13:
+ resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
+ birpc@4.1.0:
+ resolution: {integrity: sha512-O8L9vALWGqdEe0cG4HJckauw3WeJETlJnDRPUYpgwB7wrU43b/5NGMdVjdVcRo+4ROgd3ih2wha1glDe4HRVgw==}
+
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
@@ -1036,6 +1222,15 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ browserslist@4.28.8:
+ resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ bundle-name@4.1.0:
+ resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
+ engines: {node: '>=18'}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -1056,6 +1251,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ caniuse-lite@1.0.30001809:
+ resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -1099,6 +1297,9 @@ packages:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -1146,6 +1347,18 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ default-browser-id@5.0.1:
+ resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
+ engines: {node: '>=18'}
+
+ default-browser@5.5.0:
+ resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
+ engines: {node: '>=18'}
+
+ define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -1164,6 +1377,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ electron-to-chromium@1.5.405:
+ resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
+
emoji-picker-element-data@1.8.0:
resolution: {integrity: sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==}
@@ -1182,6 +1398,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
+ error-stack-parser-es@1.0.5:
+ resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
+
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1382,6 +1601,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -1463,6 +1686,11 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1475,6 +1703,15 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-in-ssh@1.0.0:
+ resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
+ engines: {node: '>=20'}
+
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
@@ -1486,6 +1723,10 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
+ engines: {node: '>=16'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1493,6 +1734,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
@@ -1500,6 +1744,11 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -1509,9 +1758,17 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kolorist@1.8.0:
+ resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
+
konsta@5.2.0:
resolution: {integrity: sha512-afWzSinsEfDU+OI7+ljShPAlZqecJlqDUSQVyvqzFy3PgoON+UQXD+f0k5ZXsQRfKmU5fyznKaHW0k8OK8nBNg==}
engines: {node: '>= 4.7.0'}
@@ -1555,24 +1812,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1603,6 +1864,9 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
lucide-vue-next@0.525.0:
resolution: {integrity: sha512-Xf8+x8B2DrnGDV/rxylS+KBp2FIe6ljwDn2JsGTZZvXIfhmm/q+nv8RuGO1OyoMjOVkkz7CqtUqJfwtFPRbB2w==}
deprecated: Package deprecated. Please use @lucide/vue instead.
@@ -1659,6 +1923,10 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1677,6 +1945,10 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
+ node-releases@2.0.53:
+ resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
+ engines: {node: '>=18'}
+
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -1688,6 +1960,13 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ obug@2.1.4:
+ resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
+ engines: {node: '>=12.20.0'}
+
+ ohash@2.0.11:
+ resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1695,6 +1974,10 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ open@11.0.0:
+ resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
+ engines: {node: '>=20'}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1742,6 +2025,9 @@ packages:
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+ perfect-debounce@2.1.0:
+ resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1770,6 +2056,10 @@ packages:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
+ powershell-utils@0.1.0:
+ resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
+ engines: {node: '>=20'}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1872,6 +2162,10 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
+ run-applescript@7.1.0:
+ resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
+ engines: {node: '>=18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -1881,6 +2175,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@@ -1928,6 +2226,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2017,6 +2319,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -2057,6 +2363,16 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
+ unplugin-utils@0.3.2:
+ resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==}
+ engines: {node: '>=20.19.0'}
+
+ update-browserslist-db@1.3.1:
+ resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2067,11 +2383,42 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vite-dev-rpc@2.0.0:
+ resolution: {integrity: sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==}
+ peerDependencies:
+ vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0
+
+ vite-hot-client@2.2.0:
+ resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==}
+ peerDependencies:
+ vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0
+
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
+ vite-plugin-inspect@11.4.1:
+ resolution: {integrity: sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@nuxt/kit': '*'
+ vite: ^6.0.0 || ^7.0.0-0 || ^8.0.0-0
+ peerDependenciesMeta:
+ '@nuxt/kit':
+ optional: true
+
+ vite-plugin-vue-devtools@8.2.1:
+ resolution: {integrity: sha512-5JLxXWWCo5lJMw16/xVeNvJ8k2zLwZPf1vITLzya/2IePrCBeGe/p/iAokgXHZpEi39fcYtPXmO8SaKeXmqCAA==}
+ engines: {node: '>=v14.21.3'}
+ peerDependencies:
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ vite-plugin-vue-inspector@6.0.0:
+ resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2192,6 +2539,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ wsl-utils@0.3.1:
+ resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
+ engines: {node: '>=20'}
+
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -2200,6 +2551,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -2214,14 +2568,192 @@ packages:
snapshots:
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-annotate-as-pure@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.8
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-member-expression-to-functions': 7.29.7
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
+ '@babel/traverse': 7.29.8
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-member-expression-to-functions@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-optimise-call-expression@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-member-expression-to-functions': 7.29.7
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/traverse': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
+ '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
+ '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.8':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
@@ -2414,6 +2946,8 @@ snapshots:
'@pkgr/core@0.3.6': {}
+ '@polka/url@1.0.0-next.29': {}
+
'@rolldown/pluginutils@1.0.1': {}
'@rollup/rollup-android-arm-eabi@4.62.4':
@@ -2908,6 +3442,35 @@ snapshots:
path-browserify: 1.0.1
vscode-uri: 3.1.0
+ '@vue/babel-helper-vue-transform-on@1.5.0': {}
+
+ '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@vue/babel-helper-vue-transform-on': 1.5.0
+ '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.7)
+ '@vue/shared': 3.5.40
+ optionalDependencies:
+ '@babel/core': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/parser': 7.29.8
+ '@vue/compiler-sfc': 3.5.40
+ transitivePeerDependencies:
+ - supports-color
+
'@vue/compiler-core@3.5.40':
dependencies:
'@babel/parser': 7.29.8
@@ -2949,6 +3512,12 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.10
+ '@vue/devtools-core@8.2.1(vue@3.5.40(typescript@5.8.3))':
+ dependencies:
+ '@vue/devtools-kit': 8.2.1
+ '@vue/devtools-shared': 8.2.1
+ vue: 3.5.40(typescript@5.8.3)
+
'@vue/devtools-kit@7.7.10':
dependencies:
'@vue/devtools-shared': 7.7.10
@@ -2959,10 +3528,19 @@ snapshots:
speakingurl: 14.0.1
superjson: 2.2.6
+ '@vue/devtools-kit@8.2.1':
+ dependencies:
+ '@vue/devtools-shared': 8.2.1
+ birpc: 2.9.0
+ hookable: 5.5.3
+ perfect-debounce: 2.1.0
+
'@vue/devtools-shared@7.7.10':
dependencies:
rfdc: 1.4.1
+ '@vue/devtools-shared@8.2.1': {}
+
'@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.5.3)':
dependencies:
eslint: 9.39.5(jiti@2.7.0)
@@ -3053,6 +3631,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansis@4.3.1: {}
+
argparse@2.0.1: {}
assertion-error@2.0.1: {}
@@ -3061,8 +3641,12 @@ snapshots:
balanced-match@4.0.4: {}
+ baseline-browser-mapping@2.11.13: {}
+
birpc@2.9.0: {}
+ birpc@4.1.0: {}
+
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
@@ -3096,6 +3680,18 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ browserslist@4.28.8:
+ dependencies:
+ baseline-browser-mapping: 2.11.13
+ caniuse-lite: 1.0.30001809
+ electron-to-chromium: 1.5.405
+ node-releases: 2.0.53
+ update-browserslist-db: 1.3.1(browserslist@4.28.8)
+
+ bundle-name@4.1.0:
+ dependencies:
+ run-applescript: 7.1.0
+
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -3112,6 +3708,8 @@ snapshots:
callsites@3.1.0: {}
+ caniuse-lite@1.0.30001809: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -3156,6 +3754,8 @@ snapshots:
content-type@2.0.0: {}
+ convert-source-map@2.0.0: {}
+
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -3189,6 +3789,15 @@ snapshots:
deep-is@0.1.4: {}
+ default-browser-id@5.0.1: {}
+
+ default-browser@5.5.0:
+ dependencies:
+ bundle-name: 4.1.0
+ default-browser-id: 5.0.1
+
+ define-lazy-prop@3.0.0: {}
+
depd@2.0.0: {}
detect-libc@2.1.2: {}
@@ -3205,6 +3814,8 @@ snapshots:
ee-first@1.1.1: {}
+ electron-to-chromium@1.5.405: {}
+
emoji-picker-element-data@1.8.0: {}
emoji-regex@8.0.0: {}
@@ -3218,6 +3829,8 @@ snapshots:
entities@7.0.1: {}
+ error-stack-parser-es@1.0.5: {}
+
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -3479,6 +4092,8 @@ snapshots:
function-bind@1.1.2: {}
+ gensync@1.0.0-beta.2: {}
+
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
@@ -3552,6 +4167,8 @@ snapshots:
ipaddr.js@1.9.1: {}
+ is-docker@3.0.0: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -3560,32 +4177,50 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-in-ssh@1.0.0: {}
+
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
is-number@7.0.0: {}
is-promise@4.0.0: {}
is-what@5.5.0: {}
+ is-wsl@3.1.1:
+ dependencies:
+ is-inside-container: 1.0.0
+
isexe@2.0.0: {}
jiti@2.7.0: {}
+ js-tokens@4.0.0: {}
+
js-tokens@9.0.1: {}
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
+ jsesc@3.1.0: {}
+
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
+ json5@2.2.3: {}
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
+ kolorist@1.8.0: {}
+
konsta@5.2.0:
dependencies:
tailwind-merge: 3.6.0
@@ -3654,6 +4289,10 @@ snapshots:
loupe@3.2.1: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
lucide-vue-next@0.525.0(vue@3.5.40(typescript@5.8.3)):
dependencies:
vue: 3.5.40(typescript@5.8.3)
@@ -3697,6 +4336,8 @@ snapshots:
mitt@3.0.1: {}
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
muggle-string@0.4.1: {}
@@ -3707,6 +4348,8 @@ snapshots:
negotiator@1.0.0: {}
+ node-releases@2.0.53: {}
+
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
@@ -3715,6 +4358,10 @@ snapshots:
object-inspect@1.13.4: {}
+ obug@2.1.4: {}
+
+ ohash@2.0.11: {}
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -3723,6 +4370,15 @@ snapshots:
dependencies:
wrappy: 1.0.2
+ open@11.0.0:
+ dependencies:
+ default-browser: 5.5.0
+ define-lazy-prop: 3.0.0
+ is-in-ssh: 1.0.0
+ is-inside-container: 1.0.0
+ powershell-utils: 0.1.0
+ wsl-utils: 0.3.1
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3762,6 +4418,8 @@ snapshots:
perfect-debounce@1.0.0: {}
+ perfect-debounce@2.1.0: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -3786,6 +4444,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ powershell-utils@0.1.0: {}
+
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -3943,6 +4603,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ run-applescript@7.1.0: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -3953,6 +4615,8 @@ snapshots:
safer-buffer@2.1.2: {}
+ semver@6.3.1: {}
+
semver@7.8.5: {}
send@1.2.1:
@@ -4020,6 +4684,12 @@ snapshots:
siginfo@2.0.0: {}
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
@@ -4089,6 +4759,8 @@ snapshots:
toidentifier@1.0.1: {}
+ totalist@3.0.1: {}
+
tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@5.8.3):
@@ -4124,6 +4796,17 @@ snapshots:
unpipe@1.0.0: {}
+ unplugin-utils@0.3.2:
+ dependencies:
+ pathe: 2.0.3
+ picomatch: 4.0.5
+
+ update-browserslist-db@1.3.1(browserslist@4.28.8):
+ dependencies:
+ browserslist: 4.28.8
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -4132,6 +4815,16 @@ snapshots:
vary@1.1.2: {}
+ vite-dev-rpc@2.0.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)):
+ dependencies:
+ birpc: 4.1.0
+ vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+ vite-hot-client: 2.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
+
+ vite-hot-client@2.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)):
+ dependencies:
+ vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+
vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
@@ -4153,6 +4846,48 @@ snapshots:
- tsx
- yaml
+ vite-plugin-inspect@11.4.1(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)):
+ dependencies:
+ ansis: 4.3.1
+ error-stack-parser-es: 1.0.5
+ obug: 2.1.4
+ ohash: 2.0.11
+ open: 11.0.0
+ perfect-debounce: 2.1.0
+ sirv: 3.0.2
+ unplugin-utils: 0.3.2
+ vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+ vite-dev-rpc: 2.0.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
+
+ vite-plugin-vue-devtools@8.2.1(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.40(typescript@5.8.3)):
+ dependencies:
+ '@vue/devtools-core': 8.2.1(vue@3.5.40(typescript@5.8.3))
+ '@vue/devtools-kit': 8.2.1
+ '@vue/devtools-shared': 8.2.1
+ sirv: 3.0.2
+ vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+ vite-plugin-inspect: 11.4.1(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
+ vite-plugin-vue-inspector: 6.0.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
+ transitivePeerDependencies:
+ - '@nuxt/kit'
+ - supports-color
+ - vue
+
+ vite-plugin-vue-inspector@6.0.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7)
+ '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
+ '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.7)
+ '@vue/compiler-dom': 3.5.40
+ kolorist: 1.8.0
+ magic-string: 0.30.21
+ vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
+ transitivePeerDependencies:
+ - supports-color
+
vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.28.1
@@ -4264,10 +4999,17 @@ snapshots:
wrappy@1.0.2: {}
+ wsl-utils@0.3.1:
+ dependencies:
+ is-wsl: 3.1.1
+ powershell-utils: 0.1.0
+
xml-name-validator@4.0.0: {}
y18n@5.0.8: {}
+ yallist@3.1.1: {}
+
yargs-parser@21.1.1: {}
yargs@17.7.2:
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 298a056..a85b7b7 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,8 +3,9 @@ import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
+import vueDevTools from 'vite-plugin-vue-devtools'
-export default defineConfig({
+export default defineConfig(({ command }) => ({
base: './',
build: {
assetsDir: 'assets',
@@ -22,7 +23,7 @@ export default defineConfig({
},
},
},
- plugins: [tailwindcss(), vue()],
+ plugins: [command === 'serve' && vueDevTools(), tailwindcss(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -37,4 +38,4 @@ export default defineConfig({
// numeric loopback origin. Keep the local UI on the localhost origin.
host: 'localhost',
},
-})
+}))
From 6b508adbc992265cc9751a305d8049e04130fc26 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Thu, 13 Aug 2026 03:08:43 +0200
Subject: [PATCH 48/63] ADD - introduce the first-party Sky UI system
---
frontend/src/main.ts | 5 +
frontend/src/ui/SkyAppPage.vue | 43 +
frontend/src/ui/SkyEmptyState.vue | 41 +
frontend/src/ui/SkyInfiniteLoader.test.ts | 37 +
frontend/src/ui/SkyInfiniteLoader.vue | 143 ++
frontend/src/ui/SkyListCard.vue | 27 +
frontend/src/ui/SkyNavbar.test.ts | 61 +
frontend/src/ui/SkyNavbar.vue | 58 +
frontend/src/ui/SkyScrollArea.vue | 25 +
frontend/src/ui/SkyScrollRail.vue | 176 +++
frontend/src/ui/SkySection.vue | 14 +
frontend/src/ui/SkyTabBar.vue | 17 +
frontend/src/ui/controls.css | 1147 +++++++++++++++++
frontend/src/ui/controls/SkyBadge.vue | 18 +
frontend/src/ui/controls/SkyBlock.vue | 30 +
frontend/src/ui/controls/SkyBlockTitle.vue | 18 +
frontend/src/ui/controls/SkyButton.vue | 82 ++
frontend/src/ui/controls/SkyCard.vue | 23 +
frontend/src/ui/controls/SkyChip.vue | 67 +
frontend/src/ui/controls/SkyField.test.ts | 115 ++
frontend/src/ui/controls/SkyField.vue | 262 ++++
frontend/src/ui/controls/SkyIcon.vue | 35 +
frontend/src/ui/controls/SkyLink.vue | 63 +
frontend/src/ui/controls/SkyList.test.ts | 23 +
frontend/src/ui/controls/SkyList.vue | 40 +
frontend/src/ui/controls/SkyListItem.vue | 121 ++
frontend/src/ui/controls/SkyProgress.vue | 38 +
frontend/src/ui/controls/SkyRadio.vue | 86 ++
frontend/src/ui/controls/SkyRange.test.ts | 44 +
frontend/src/ui/controls/SkyRange.vue | 102 ++
frontend/src/ui/controls/SkySearchbar.vue | 107 ++
frontend/src/ui/controls/SkySegmented.vue | 23 +
.../src/ui/controls/SkySegmentedButton.vue | 34 +
frontend/src/ui/controls/SkySpinner.vue | 32 +
.../src/ui/controls/SkyStatusCard.test.ts | 34 +
frontend/src/ui/controls/SkyStatusCard.vue | 58 +
frontend/src/ui/controls/SkySurface.vue | 25 +
frontend/src/ui/controls/SkyTabButton.vue | 45 +
frontend/src/ui/controls/SkyToggle.vue | 72 ++
frontend/src/ui/controls/SkyToolbarPane.vue | 18 +
frontend/src/ui/controls/index.ts | 23 +
.../ui/controls/useComposedFieldValue.test.ts | 49 +
.../src/ui/controls/useComposedFieldValue.ts | 46 +
frontend/src/ui/foundation.css | 449 +++++++
frontend/src/ui/index.ts | 12 +
frontend/src/ui/overlays.css | 266 ++++
frontend/src/ui/overlays/SkyActionButton.vue | 17 +
frontend/src/ui/overlays/SkyActionGroup.vue | 3 +
frontend/src/ui/overlays/SkyActionSheet.vue | 81 ++
frontend/src/ui/overlays/SkyDialog.vue | 64 +
frontend/src/ui/overlays/SkyDialogButton.vue | 17 +
frontend/src/ui/overlays/SkyMessage.vue | 19 +
frontend/src/ui/overlays/SkyMessagebar.vue | 29 +
frontend/src/ui/overlays/SkyMessages.vue | 3 +
frontend/src/ui/overlays/SkyMessagesTitle.vue | 3 +
frontend/src/ui/overlays/SkySheet.vue | 80 ++
frontend/src/ui/overlays/SkyToast.vue | 21 +
frontend/src/ui/overlays/index.ts | 11 +
.../src/ui/overlays/useOverlayFocusTrap.ts | 323 +++++
frontend/src/ui/settings.css | 263 ++++
.../src/ui/settings/SkySettingsGroup.test.ts | 52 +
frontend/src/ui/settings/SkySettingsGroup.vue | 50 +
.../src/ui/settings/SkySettingsIcon.test.ts | 37 +
frontend/src/ui/settings/SkySettingsIcon.vue | 30 +
.../ui/settings/SkySettingsRangeRow.test.ts | 64 +
.../src/ui/settings/SkySettingsRangeRow.vue | 73 ++
.../src/ui/settings/SkySettingsRow.test.ts | 136 ++
frontend/src/ui/settings/SkySettingsRow.vue | 213 +++
frontend/src/ui/settings/index.ts | 4 +
frontend/src/ui/tokens.css | 56 +
frontend/src/utils/scrollRail.test.ts | 90 ++
frontend/src/utils/scrollRail.ts | 52 +
72 files changed, 6045 insertions(+)
create mode 100644 frontend/src/ui/SkyAppPage.vue
create mode 100644 frontend/src/ui/SkyEmptyState.vue
create mode 100644 frontend/src/ui/SkyInfiniteLoader.test.ts
create mode 100644 frontend/src/ui/SkyInfiniteLoader.vue
create mode 100644 frontend/src/ui/SkyListCard.vue
create mode 100644 frontend/src/ui/SkyNavbar.test.ts
create mode 100644 frontend/src/ui/SkyNavbar.vue
create mode 100644 frontend/src/ui/SkyScrollArea.vue
create mode 100644 frontend/src/ui/SkyScrollRail.vue
create mode 100644 frontend/src/ui/SkySection.vue
create mode 100644 frontend/src/ui/SkyTabBar.vue
create mode 100644 frontend/src/ui/controls.css
create mode 100644 frontend/src/ui/controls/SkyBadge.vue
create mode 100644 frontend/src/ui/controls/SkyBlock.vue
create mode 100644 frontend/src/ui/controls/SkyBlockTitle.vue
create mode 100644 frontend/src/ui/controls/SkyButton.vue
create mode 100644 frontend/src/ui/controls/SkyCard.vue
create mode 100644 frontend/src/ui/controls/SkyChip.vue
create mode 100644 frontend/src/ui/controls/SkyField.test.ts
create mode 100644 frontend/src/ui/controls/SkyField.vue
create mode 100644 frontend/src/ui/controls/SkyIcon.vue
create mode 100644 frontend/src/ui/controls/SkyLink.vue
create mode 100644 frontend/src/ui/controls/SkyList.test.ts
create mode 100644 frontend/src/ui/controls/SkyList.vue
create mode 100644 frontend/src/ui/controls/SkyListItem.vue
create mode 100644 frontend/src/ui/controls/SkyProgress.vue
create mode 100644 frontend/src/ui/controls/SkyRadio.vue
create mode 100644 frontend/src/ui/controls/SkyRange.test.ts
create mode 100644 frontend/src/ui/controls/SkyRange.vue
create mode 100644 frontend/src/ui/controls/SkySearchbar.vue
create mode 100644 frontend/src/ui/controls/SkySegmented.vue
create mode 100644 frontend/src/ui/controls/SkySegmentedButton.vue
create mode 100644 frontend/src/ui/controls/SkySpinner.vue
create mode 100644 frontend/src/ui/controls/SkyStatusCard.test.ts
create mode 100644 frontend/src/ui/controls/SkyStatusCard.vue
create mode 100644 frontend/src/ui/controls/SkySurface.vue
create mode 100644 frontend/src/ui/controls/SkyTabButton.vue
create mode 100644 frontend/src/ui/controls/SkyToggle.vue
create mode 100644 frontend/src/ui/controls/SkyToolbarPane.vue
create mode 100644 frontend/src/ui/controls/index.ts
create mode 100644 frontend/src/ui/controls/useComposedFieldValue.test.ts
create mode 100644 frontend/src/ui/controls/useComposedFieldValue.ts
create mode 100644 frontend/src/ui/foundation.css
create mode 100644 frontend/src/ui/index.ts
create mode 100644 frontend/src/ui/overlays.css
create mode 100644 frontend/src/ui/overlays/SkyActionButton.vue
create mode 100644 frontend/src/ui/overlays/SkyActionGroup.vue
create mode 100644 frontend/src/ui/overlays/SkyActionSheet.vue
create mode 100644 frontend/src/ui/overlays/SkyDialog.vue
create mode 100644 frontend/src/ui/overlays/SkyDialogButton.vue
create mode 100644 frontend/src/ui/overlays/SkyMessage.vue
create mode 100644 frontend/src/ui/overlays/SkyMessagebar.vue
create mode 100644 frontend/src/ui/overlays/SkyMessages.vue
create mode 100644 frontend/src/ui/overlays/SkyMessagesTitle.vue
create mode 100644 frontend/src/ui/overlays/SkySheet.vue
create mode 100644 frontend/src/ui/overlays/SkyToast.vue
create mode 100644 frontend/src/ui/overlays/index.ts
create mode 100644 frontend/src/ui/overlays/useOverlayFocusTrap.ts
create mode 100644 frontend/src/ui/settings.css
create mode 100644 frontend/src/ui/settings/SkySettingsGroup.test.ts
create mode 100644 frontend/src/ui/settings/SkySettingsGroup.vue
create mode 100644 frontend/src/ui/settings/SkySettingsIcon.test.ts
create mode 100644 frontend/src/ui/settings/SkySettingsIcon.vue
create mode 100644 frontend/src/ui/settings/SkySettingsRangeRow.test.ts
create mode 100644 frontend/src/ui/settings/SkySettingsRangeRow.vue
create mode 100644 frontend/src/ui/settings/SkySettingsRow.test.ts
create mode 100644 frontend/src/ui/settings/SkySettingsRow.vue
create mode 100644 frontend/src/ui/settings/index.ts
create mode 100644 frontend/src/ui/tokens.css
create mode 100644 frontend/src/utils/scrollRail.test.ts
create mode 100644 frontend/src/utils/scrollRail.ts
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index f85ef02..249a09b 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,5 +4,10 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
+import './ui/tokens.css'
+import './ui/foundation.css'
+import './ui/controls.css'
+import './ui/settings.css'
+import './ui/overlays.css'
createApp(App).use(createPinia()).use(router).mount('#app')
diff --git a/frontend/src/ui/SkyAppPage.vue b/frontend/src/ui/SkyAppPage.vue
new file mode 100644
index 0000000..8c3f9da
--- /dev/null
+++ b/frontend/src/ui/SkyAppPage.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/SkyEmptyState.vue b/frontend/src/ui/SkyEmptyState.vue
new file mode 100644
index 0000000..0175985
--- /dev/null
+++ b/frontend/src/ui/SkyEmptyState.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ title }}
+ {{ body }}
+
+
+
+
+
diff --git a/frontend/src/ui/SkyInfiniteLoader.test.ts b/frontend/src/ui/SkyInfiniteLoader.test.ts
new file mode 100644
index 0000000..b0fa13f
--- /dev/null
+++ b/frontend/src/ui/SkyInfiniteLoader.test.ts
@@ -0,0 +1,37 @@
+import { createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+import { describe, expect, it } from 'vitest'
+
+import SkyInfiniteLoader from '@/ui/SkyInfiniteLoader.vue'
+
+function renderLoader(error: boolean | string): Promise {
+ const app = createSSRApp(SkyInfiniteLoader, {
+ error,
+ hasMore: false,
+ loading: false,
+ loadingLabel: 'Loading more companies',
+ loadKey: null,
+ retryLabel: 'Try Again',
+ })
+
+ return renderToString(app)
+}
+
+describe('SkyInfiniteLoader error rendering', () => {
+ it('does not Boolean-cast a bound empty error string to true', async () => {
+ const html = await renderLoader('')
+
+ expect(html).not.toContain('sky-infinite-loader__retry')
+ expect(html).not.toContain('Try Again')
+ })
+
+ it.each([true, 'append_failed'])(
+ 'renders retry for error %j',
+ async (error) => {
+ const html = await renderLoader(error)
+
+ expect(html).toContain('sky-infinite-loader__retry')
+ expect(html).toContain('Try Again')
+ },
+ )
+})
diff --git a/frontend/src/ui/SkyInfiniteLoader.vue b/frontend/src/ui/SkyInfiniteLoader.vue
new file mode 100644
index 0000000..74a865c
--- /dev/null
+++ b/frontend/src/ui/SkyInfiniteLoader.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+ {{ retryLabel }}
+
+
+
diff --git a/frontend/src/ui/SkyListCard.vue b/frontend/src/ui/SkyListCard.vue
new file mode 100644
index 0000000..369486b
--- /dev/null
+++ b/frontend/src/ui/SkyListCard.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/frontend/src/ui/SkyNavbar.test.ts b/frontend/src/ui/SkyNavbar.test.ts
new file mode 100644
index 0000000..8df1210
--- /dev/null
+++ b/frontend/src/ui/SkyNavbar.test.ts
@@ -0,0 +1,61 @@
+import { readFileSync } from 'node:fs'
+
+import { createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+import { describe, expect, it } from 'vitest'
+
+import SkyNavbar from '@/ui/SkyNavbar.vue'
+
+const foundationStyles = readFileSync(
+ new URL('./foundation.css', import.meta.url),
+ 'utf8',
+)
+
+describe('SkyNavbar', () => {
+ it('keeps the compact centered header as the default', async () => {
+ const html = await renderToString(
+ createSSRApp(SkyNavbar, { title: 'Account' }),
+ )
+
+ expect(html).toContain('sky-navbar--compact')
+ expect(html).toContain('Account
')
+ })
+
+ it('exposes the large-title header without changing heading semantics', async () => {
+ const html = await renderToString(
+ createSSRApp(SkyNavbar, {
+ title: 'Settings',
+ variant: 'large',
+ }),
+ )
+
+ expect(html).toContain('sky-navbar--large')
+ expect(html).toContain('Settings
')
+ })
+
+ it('does not reserve a second navigation row for the large title', () => {
+ const largeNavbarRule = foundationStyles.match(
+ /\.sky-navbar--large\s*\{(?[^}]*)\}/,
+ )?.groups?.declarations
+
+ expect(largeNavbarRule).toBeDefined()
+ expect(largeNavbarRule).toContain(
+ 'grid-template-rows: var(--sky-navbar-large-title-height)',
+ )
+ expect(largeNavbarRule).not.toContain('var(--sky-navbar-height)')
+ })
+
+ it('exposes the optional surface back affordance for detail screens', async () => {
+ const html = await renderToString(
+ createSSRApp(SkyNavbar, {
+ backAppearance: 'surface',
+ backLabel: 'Back to Settings',
+ showBack: true,
+ title: 'Account',
+ }),
+ )
+
+ expect(html).toContain('sky-navbar__back--surface')
+ expect(html).toContain('aria-label="Back to Settings"')
+ })
+})
diff --git a/frontend/src/ui/SkyNavbar.vue b/frontend/src/ui/SkyNavbar.vue
new file mode 100644
index 0000000..c811aa8
--- /dev/null
+++ b/frontend/src/ui/SkyNavbar.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/frontend/src/ui/SkyScrollArea.vue b/frontend/src/ui/SkyScrollArea.vue
new file mode 100644
index 0000000..66c3b90
--- /dev/null
+++ b/frontend/src/ui/SkyScrollArea.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/SkyScrollRail.vue b/frontend/src/ui/SkyScrollRail.vue
new file mode 100644
index 0000000..0e85336
--- /dev/null
+++ b/frontend/src/ui/SkyScrollRail.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/SkySection.vue b/frontend/src/ui/SkySection.vue
new file mode 100644
index 0000000..633d1a4
--- /dev/null
+++ b/frontend/src/ui/SkySection.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/frontend/src/ui/SkyTabBar.vue b/frontend/src/ui/SkyTabBar.vue
new file mode 100644
index 0000000..b4497b6
--- /dev/null
+++ b/frontend/src/ui/SkyTabBar.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/frontend/src/ui/controls.css b/frontend/src/ui/controls.css
new file mode 100644
index 0000000..e19b726
--- /dev/null
+++ b/frontend/src/ui/controls.css
@@ -0,0 +1,1147 @@
+.sky-button,
+.sky-link,
+.sky-chip,
+.sky-field__clear,
+.sky-list-item__row,
+.sky-searchbar__clear,
+.sky-segmented-button,
+.sky-tab-button {
+ box-sizing: border-box;
+ border: 0;
+ font: inherit;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.sky-button,
+.sky-link,
+.sky-chip,
+.sky-list-item__row,
+.sky-segmented-button,
+.sky-tab-button {
+ min-height: var(--sky-touch-target, 44px);
+}
+
+.sky-button,
+.sky-link,
+.sky-chip,
+.sky-segmented-button,
+.sky-tab-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
+ user-select: none;
+}
+
+.sky-button:focus-visible,
+.sky-link:focus-visible,
+.sky-chip:focus-visible,
+.sky-field__clear:focus-visible,
+.sky-list-item__row:focus-visible,
+.sky-segmented-button:focus-visible,
+.sky-tab-button:focus-visible {
+ outline: 2px solid var(--sky-app-accent, #3b82f6);
+ outline-offset: 2px;
+}
+
+.sky-button {
+ min-width: var(--sky-touch-target, 44px);
+ gap: 7px;
+ padding: 0 16px;
+ border: 1px solid transparent;
+ border-radius: var(--sky-radius-control, 12px);
+ background: var(--sky-app-accent, #3b82f6);
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: 650;
+ line-height: 1.2;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ opacity 140ms ease,
+ transform 100ms ease;
+}
+
+.sky-button:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.sky-button--secondary {
+ background: var(--sky-surface-muted, #e6e9ee);
+ color: var(--sky-text, #111827);
+}
+
+.sky-button--danger {
+ background: var(--sky-danger, #dc2626);
+}
+
+.sky-button--plain {
+ background: transparent;
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-button--outline {
+ border-color: var(--sky-hairline, rgba(15, 23, 42, 0.14));
+ background: transparent;
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-button--danger.sky-button--outline {
+ border-color: var(--sky-danger, #dc2626);
+ color: var(--sky-danger, #dc2626);
+}
+
+.sky-button--rounded {
+ border-radius: 999px;
+}
+
+.sky-button--large {
+ min-height: 50px;
+ font-size: 15px;
+}
+
+.sky-button--block {
+ width: 100%;
+}
+
+.sky-button--icon-only {
+ width: var(--sky-touch-target, 44px);
+ padding: 0;
+}
+
+.sky-button:disabled,
+.sky-button[aria-disabled='true'],
+.sky-link:disabled,
+.sky-link[aria-disabled='true'],
+.sky-chip:disabled,
+.sky-chip[aria-disabled='true'],
+.sky-tab-button:disabled {
+ cursor: default;
+ opacity: 0.42;
+}
+
+.sky-link {
+ min-width: var(--sky-touch-target, 44px);
+ gap: 6px;
+ padding: 0 8px;
+ border-radius: var(--sky-radius-control, 12px);
+ background: transparent;
+ color: var(--sky-app-accent, #3b82f6);
+ font-size: 14px;
+}
+
+.sky-link:active:not(:disabled) {
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+}
+
+.sky-link--icon-only {
+ width: var(--sky-touch-target, 44px);
+ padding: 0;
+ border-radius: 50%;
+}
+
+.sky-badge {
+ min-width: 18px;
+ min-height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: var(--sky-surface-muted, #e6e9ee);
+ color: var(--sky-muted, #64748b);
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.sky-badge--info {
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+ color: var(--sky-app-accent, #2563eb);
+}
+
+.sky-badge--success {
+ background: rgba(5, 150, 105, 0.14);
+ color: var(--sky-success, #059669);
+}
+
+.sky-badge--warning {
+ background: rgba(245, 158, 11, 0.16);
+ color: #b45309;
+}
+
+.sky-badge--danger {
+ background: rgba(220, 38, 38, 0.14);
+ color: var(--sky-danger, #dc2626);
+}
+
+.sky-block {
+ min-width: 0;
+ margin: var(--sky-space-3, 12px) 0;
+}
+
+.sky-block--inset {
+ margin-right: var(--sky-page-gutter, 14px);
+ margin-left: var(--sky-page-gutter, 14px);
+}
+
+.sky-block--strong {
+ padding: var(--sky-space-4, 16px);
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: var(--sky-radius-card, 16px);
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-block-title {
+ margin: var(--sky-space-5, 20px) 2px var(--sky-space-2, 8px);
+ padding: 0;
+ color: var(--sky-muted, #64748b);
+ font-size: 14px;
+ font-weight: 650;
+ line-height: 20px;
+}
+
+.sky-card,
+.sky-surface {
+ min-width: 0;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: var(--sky-radius-card, 16px);
+ background: var(--sky-surface, #ffffff);
+ color: var(--sky-text, #111827);
+}
+
+.sky-card__content {
+ min-width: 0;
+ padding: var(--sky-space-4, 16px);
+}
+
+.sky-status-card {
+ min-width: 0;
+ padding: 14px;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--sky-space-3, 12px);
+}
+
+.sky-status-card__icon {
+ width: 40px;
+ height: 40px;
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ border-radius: var(--sky-radius-control, 12px);
+ background: var(--sky-surface-muted, #e6e9ee);
+ color: var(--sky-muted, #64748b);
+}
+
+.sky-status-card__copy {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sky-status-card__title,
+.sky-status-card__subtitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sky-status-card__title {
+ color: var(--sky-text, #111827);
+ font-size: 15px;
+ font-weight: 650;
+ line-height: 20px;
+}
+
+.sky-status-card__subtitle {
+ color: var(--sky-muted, #64748b);
+ font-size: 12px;
+ line-height: 16px;
+}
+
+.sky-status-card__trailing {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.sky-status-card__indicator {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--sky-muted, #64748b);
+}
+
+.sky-status-card--accent .sky-status-card__icon {
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-status-card--accent .sky-status-card__indicator {
+ background: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-status-card--success .sky-status-card__icon {
+ background: var(--sky-success-soft, rgba(5, 150, 105, 0.14));
+ color: var(--sky-success, #059669);
+}
+
+.sky-status-card--success .sky-status-card__indicator {
+ background: var(--sky-success, #059669);
+}
+
+.sky-status-card--warning .sky-status-card__icon {
+ background: var(--sky-warning-soft, rgba(245, 158, 11, 0.16));
+ color: var(--sky-warning, #b45309);
+}
+
+.sky-status-card--warning .sky-status-card__indicator {
+ background: var(--sky-warning, #b45309);
+}
+
+.sky-status-card--danger .sky-status-card__icon {
+ background: var(--sky-danger-soft, rgba(220, 38, 38, 0.14));
+ color: var(--sky-danger, #dc2626);
+}
+
+.sky-status-card--danger .sky-status-card__indicator {
+ background: var(--sky-danger, #dc2626);
+}
+
+.sky-surface {
+ padding: var(--sky-space-3, 12px);
+}
+
+.sky-surface--highlight {
+ border-color: var(--sky-app-accent, #3b82f6);
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+}
+
+.sky-chip {
+ min-width: var(--sky-touch-target, 44px);
+ flex: none;
+ gap: 5px;
+ padding: 0 13px;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: 999px;
+ background: var(--sky-surface-muted, #e6e9ee);
+ color: var(--sky-text, #111827);
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.sky-chip--selected {
+ border-color: var(--sky-app-accent, #3b82f6);
+ background: var(--sky-app-accent, #3b82f6);
+ color: #ffffff;
+}
+
+.sky-icon {
+ width: 1.5em;
+ height: 1.5em;
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+}
+
+.sky-icon > svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.sky-list {
+ margin: var(--sky-space-3, 12px) 0;
+ padding: 0;
+ list-style: none;
+}
+
+.sky-list--inset {
+ overflow: hidden;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: var(--sky-radius-card, 16px);
+}
+
+.sky-list--strong,
+.sky-list--inset {
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-list--nested {
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+}
+
+.sky-list--flush {
+ margin: 0;
+}
+
+.sky-list--compact > .sky-list-item > .sky-list-item__row,
+.sky-list--compact > .sky-field {
+ padding-top: 7px;
+ padding-bottom: 7px;
+}
+
+.sky-list--compact
+ > .sky-field:not(.sky-field--inline):not(.sky-field--outline)
+ .sky-field__label {
+ position: absolute;
+ z-index: 1;
+ top: 9px;
+ left: 12px;
+ margin: 0;
+ pointer-events: none;
+}
+
+.sky-list--compact
+ > .sky-field:not(.sky-field--inline):not(.sky-field--outline)
+ .sky-field__input {
+ padding-top: 14px;
+}
+
+.sky-list--compact
+ > .sky-field:not(.sky-field--inline):not(.sky-field--outline)
+ .sky-field__leading,
+.sky-list--compact
+ > .sky-field:not(.sky-field--inline):not(.sky-field--outline)
+ .sky-field__trailing {
+ transform: translateY(7px);
+}
+
+.sky-list-item,
+.sky-field {
+ position: relative;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.sky-list-item + .sky-list-item::before,
+.sky-field + .sky-field::before,
+.sky-list-item + .sky-field::before,
+.sky-field + .sky-list-item::before {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 12px;
+ left: 12px;
+ height: 1px;
+ content: '';
+ background: var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ pointer-events: none;
+}
+
+.sky-list-item__row {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 10px 12px;
+ background: transparent;
+ color: var(--sky-text, #111827);
+ text-align: left;
+ text-decoration: none;
+}
+
+button.sky-list-item__row,
+a.sky-list-item__row,
+label.sky-list-item__row {
+ cursor: pointer;
+}
+
+.sky-list-item__row:active {
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+}
+
+.sky-list-item--disabled {
+ opacity: 0.45;
+}
+
+.sky-list-item__media,
+.sky-list-item__after {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+}
+
+.sky-list-item__media {
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-list-item__content {
+ min-width: 0;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sky-list-item__header {
+ overflow: hidden;
+ color: var(--sky-muted, #64748b);
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sky-list-item__title {
+ overflow: hidden;
+ color: var(--sky-text, #111827);
+ font-size: 15px;
+ font-weight: 650;
+ line-height: 20px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sky-list-item__subtitle {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--sky-muted, #64748b);
+ font-size: 12px;
+ line-height: 16px;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.sky-list-item__after {
+ max-width: 42%;
+ color: var(--sky-muted, #64748b);
+ font-size: 12px;
+ text-align: right;
+}
+
+.sky-list-item__chevron {
+ width: 8px;
+ height: 8px;
+ flex: none;
+ border-top: 1.5px solid var(--sky-muted, #64748b);
+ border-right: 1.5px solid var(--sky-muted, #64748b);
+ opacity: 0.65;
+ transform: rotate(45deg);
+}
+
+.sky-field {
+ padding: 10px 12px;
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-field__label {
+ display: block;
+ margin-bottom: 5px;
+ color: var(--sky-muted, #64748b);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 15px;
+}
+
+.sky-field__control {
+ min-width: 0;
+ min-height: var(--sky-touch-target, 44px);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.sky-field--outline .sky-field__control {
+ padding: 0 10px;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.14));
+ border-radius: var(--sky-radius-control, 12px);
+}
+
+.sky-field--outline:focus-within .sky-field__control {
+ border-color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-field__input {
+ min-width: 0;
+ min-height: var(--sky-touch-target, 44px);
+ padding: 0;
+ flex: 1;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--sky-text, #111827);
+ font: inherit;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.sky-field__input::placeholder {
+ color: var(--sky-muted, #64748b);
+ opacity: 0.72;
+}
+
+.sky-field__textarea {
+ min-height: 72px;
+ padding: 10px 0;
+ resize: vertical;
+}
+
+.sky-field__leading,
+.sky-field__trailing {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ color: var(--sky-muted, #64748b);
+}
+
+.sky-field--has-leading .sky-field__label {
+ padding-left: 28px;
+}
+
+.sky-field__leading {
+ min-width: 20px;
+ justify-content: center;
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-field__clear {
+ width: var(--sky-touch-target, 44px);
+ height: var(--sky-touch-target, 44px);
+ display: grid;
+ flex: none;
+ place-items: center;
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+}
+
+.sky-field__clear > span {
+ width: 18px;
+ height: 18px;
+ position: relative;
+ display: block;
+ border-radius: 50%;
+ background: var(--sky-muted, #64748b);
+}
+
+.sky-field__clear > span::before,
+.sky-field__clear > span::after {
+ width: 9px;
+ height: 1.5px;
+ position: absolute;
+ top: 8px;
+ left: 4.5px;
+ content: '';
+ border-radius: 2px;
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-field__clear > span::before {
+ transform: rotate(45deg);
+}
+
+.sky-field__clear > span::after {
+ transform: rotate(-45deg);
+}
+
+.sky-field__clear:disabled {
+ cursor: default;
+ opacity: 0.42;
+}
+
+.sky-field__input[type='number'] {
+ appearance: textfield;
+}
+
+.sky-field__input[type='number']::-webkit-inner-spin-button,
+.sky-field__input[type='number']::-webkit-outer-spin-button {
+ margin: 0;
+ appearance: none;
+}
+
+.sky-field__help,
+.sky-field__error {
+ display: block;
+ margin-top: 5px;
+ font-size: 11px;
+ line-height: 15px;
+}
+
+.sky-field__help {
+ color: var(--sky-muted, #64748b);
+}
+
+.sky-field__error,
+.sky-field--error .sky-field__label {
+ color: var(--sky-danger, #dc2626);
+}
+
+.sky-field--error .sky-field__control {
+ border-color: var(--sky-danger, #dc2626);
+}
+
+.sky-field--disabled {
+ opacity: 0.45;
+}
+
+.sky-searchbar {
+ min-width: 0;
+ min-height: var(--sky-touch-target, 44px);
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 8px 0 13px;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: var(--sky-radius-control, 12px);
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-searchbar:focus-within {
+ border-color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-searchbar__icon {
+ width: 14px;
+ height: 14px;
+ position: relative;
+ flex: none;
+ border: 1.6px solid var(--sky-muted, #64748b);
+ border-radius: 50%;
+ opacity: 0.8;
+}
+
+.sky-searchbar__icon::after {
+ width: 6px;
+ height: 1.6px;
+ position: absolute;
+ right: -5px;
+ bottom: -2px;
+ content: '';
+ border-radius: 2px;
+ background: var(--sky-muted, #64748b);
+ transform: rotate(45deg);
+ transform-origin: left center;
+}
+
+.sky-searchbar__input {
+ min-width: 0;
+ min-height: 42px;
+ padding: 0 4px;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ color: var(--sky-text, #111827);
+ font: inherit;
+ font-size: 14px;
+}
+
+.sky-searchbar__input::-webkit-search-cancel-button {
+ display: none;
+}
+
+.sky-searchbar__input::placeholder {
+ color: var(--sky-muted, #64748b);
+ opacity: 0.72;
+}
+
+.sky-searchbar__clear {
+ width: var(--sky-touch-target, 44px);
+ height: var(--sky-touch-target, 44px);
+ position: relative;
+ display: grid;
+ flex: none;
+ place-items: center;
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+}
+
+.sky-searchbar__clear > span {
+ width: 18px;
+ height: 18px;
+ position: relative;
+ display: block;
+ border-radius: 50%;
+ background: var(--sky-muted, #64748b);
+}
+
+.sky-searchbar__clear > span::before,
+.sky-searchbar__clear > span::after {
+ width: 9px;
+ height: 1.5px;
+ position: absolute;
+ top: 8px;
+ left: 4.5px;
+ content: '';
+ border-radius: 2px;
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-searchbar__clear > span::before {
+ transform: rotate(45deg);
+}
+
+.sky-searchbar__clear > span::after {
+ transform: rotate(-45deg);
+}
+
+.sky-searchbar--disabled {
+ opacity: 0.45;
+}
+
+.sky-toggle,
+.sky-radio {
+ min-width: var(--sky-touch-target, 44px);
+ min-height: var(--sky-touch-target, 44px);
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ cursor: pointer;
+}
+
+.sky-toggle__input,
+.sky-radio__input {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ inset: 0;
+ margin: 0;
+ opacity: 0;
+ cursor: inherit;
+}
+
+.sky-toggle__track {
+ width: 42px;
+ height: 24px;
+ position: relative;
+ flex: none;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.14));
+ border-radius: 999px;
+ background: var(--sky-surface-muted, #e6e9ee);
+ transition: background-color 150ms ease;
+}
+
+.sky-toggle__thumb {
+ width: 20px;
+ height: 20px;
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 1px 4px rgba(15, 23, 42, 0.28);
+ transition: transform 150ms ease;
+}
+
+.sky-toggle--checked .sky-toggle__track {
+ border-color: var(--sky-app-accent, #3b82f6);
+ background: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-toggle--checked .sky-toggle__thumb {
+ transform: translateX(18px);
+}
+
+.sky-toggle__input:focus-visible + .sky-toggle__track,
+.sky-radio__input:focus-visible + .sky-radio__mark {
+ outline: 2px solid var(--sky-app-accent, #3b82f6);
+ outline-offset: 2px;
+}
+
+.sky-toggle--disabled,
+.sky-radio--disabled {
+ cursor: default;
+ opacity: 0.45;
+}
+
+.sky-toggle__label,
+.sky-radio__label {
+ color: var(--sky-text, #111827);
+ font-size: 14px;
+}
+
+.sky-radio__mark {
+ width: 22px;
+ height: 22px;
+ position: relative;
+ flex: none;
+ border: 2px solid var(--sky-muted, #64748b);
+ border-radius: 50%;
+ background: var(--sky-surface, #ffffff);
+}
+
+.sky-radio--checked .sky-radio__mark {
+ border-color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-radio--checked .sky-radio__mark::after {
+ width: 12px;
+ height: 12px;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ content: '';
+ border-radius: 50%;
+ background: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-range {
+ min-width: 0;
+ min-height: var(--sky-touch-target, 44px);
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+.sky-range--captioned {
+ position: relative;
+}
+
+.sky-range__caption {
+ position: absolute;
+ z-index: 1;
+ top: 2px;
+ left: 0;
+ color: var(--sky-muted, #64748b);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 15px;
+ pointer-events: none;
+}
+
+.sky-range--captioned .sky-range__input {
+ padding-top: 14px;
+}
+
+.sky-range--captioned .sky-range__label {
+ transform: translateY(7px);
+}
+
+.sky-range__input {
+ width: 100%;
+ min-width: 0;
+ height: var(--sky-touch-target, 44px);
+ margin: 0;
+ padding: 0;
+ flex: 1;
+ appearance: none;
+ outline: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.sky-range__input::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: 999px;
+ background: linear-gradient(
+ to right,
+ var(--sky-app-accent, #3b82f6) 0,
+ var(--sky-app-accent, #3b82f6) var(--sky-range-progress),
+ var(--sky-surface-muted, #e6e9ee) var(--sky-range-progress),
+ var(--sky-surface-muted, #e6e9ee) 100%
+ );
+}
+
+.sky-range__input::-webkit-slider-thumb {
+ width: 22px;
+ height: 22px;
+ margin-top: -9px;
+ appearance: none;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.14));
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 1px 5px rgba(15, 23, 42, 0.28);
+}
+
+.sky-range__input:focus-visible::-webkit-slider-thumb {
+ box-shadow:
+ 0 0 0 3px var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14)),
+ 0 1px 5px rgba(15, 23, 42, 0.28);
+}
+
+.sky-range--disabled {
+ cursor: default;
+ opacity: 0.45;
+}
+
+.sky-range--disabled .sky-range__input {
+ cursor: default;
+}
+
+.sky-range__label {
+ color: var(--sky-muted, #64748b);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.sky-segmented {
+ min-width: 0;
+ min-height: var(--sky-touch-target, 44px);
+ display: flex;
+ align-items: stretch;
+ gap: 3px;
+ padding: 3px;
+ border: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
+ border-radius: var(--sky-radius-control, 12px);
+ background: var(--sky-surface-muted, #e6e9ee);
+}
+
+.sky-segmented-button {
+ min-width: 0;
+ min-height: 36px;
+ padding: 0 10px;
+ flex: 1 1 0;
+ border-radius: 9px;
+ background: transparent;
+ color: var(--sky-muted, #64748b);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.sky-segmented-button--active {
+ background: var(--sky-surface, #ffffff);
+ color: var(--sky-text, #111827);
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.14);
+}
+
+.sky-segmented-button:disabled {
+ cursor: default;
+ opacity: 0.4;
+}
+
+.sky-spinner {
+ width: 20px;
+ height: 20px;
+ box-sizing: border-box;
+ display: inline-block;
+ flex: none;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: sky-spinner-turn 700ms linear infinite;
+}
+
+@keyframes sky-spinner-turn {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.sky-progress {
+ width: 100%;
+ height: 5px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--sky-surface-muted, #e6e9ee);
+}
+
+.sky-progress__value {
+ height: 100%;
+ display: block;
+ border-radius: inherit;
+ background: var(--sky-app-accent, #3b82f6);
+ transition: width 180ms ease;
+}
+
+.sky-tab-button {
+ min-width: 0;
+ min-height: var(--sky-tabbar-height, 64px);
+ position: relative;
+ flex: 1 1 0;
+ flex-direction: column;
+ gap: 3px;
+ padding: 6px 4px;
+ border-radius: 0;
+ background: transparent;
+ color: var(--sky-muted, #64748b);
+}
+
+.sky-tab-button--active {
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-tabbar .sky-tab-button {
+ color: var(--sky-muted, #64748b);
+}
+
+.sky-tabbar .sky-tab-button--active {
+ color: var(--sky-app-accent, #3b82f6);
+}
+
+.sky-tab-button:active:not(:disabled) {
+ background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
+}
+
+.sky-tab-button__icon {
+ width: 25px;
+ height: 25px;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.sky-tab-button__label {
+ max-width: 100%;
+ overflow: hidden;
+ font-size: 11px;
+ font-weight: 650;
+ line-height: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sky-tab-button__icon,
+.sky-tab-button__label {
+ z-index: 1;
+}
+
+.sky-tab-button--active .sky-tab-button__label {
+ font-weight: 750;
+}
+
+.sky-toolbar-pane {
+ min-width: 0;
+ display: flex;
+ align-items: stretch;
+}
+
+.sky-visually-hidden {
+ width: 1px;
+ height: 1px;
+ position: absolute;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sky-button,
+ .sky-progress__value,
+ .sky-toggle__track,
+ .sky-toggle__thumb {
+ transition-duration: 0.01ms;
+ }
+
+ .sky-spinner {
+ animation-duration: 1.5s;
+ }
+}
diff --git a/frontend/src/ui/controls/SkyBadge.vue b/frontend/src/ui/controls/SkyBadge.vue
new file mode 100644
index 0000000..2118426
--- /dev/null
+++ b/frontend/src/ui/controls/SkyBadge.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyBlock.vue b/frontend/src/ui/controls/SkyBlock.vue
new file mode 100644
index 0000000..adf6c65
--- /dev/null
+++ b/frontend/src/ui/controls/SkyBlock.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyBlockTitle.vue b/frontend/src/ui/controls/SkyBlockTitle.vue
new file mode 100644
index 0000000..38f6fa6
--- /dev/null
+++ b/frontend/src/ui/controls/SkyBlockTitle.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyButton.vue b/frontend/src/ui/controls/SkyButton.vue
new file mode 100644
index 0000000..ee3568b
--- /dev/null
+++ b/frontend/src/ui/controls/SkyButton.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyCard.vue b/frontend/src/ui/controls/SkyCard.vue
new file mode 100644
index 0000000..af6b08d
--- /dev/null
+++ b/frontend/src/ui/controls/SkyCard.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyChip.vue b/frontend/src/ui/controls/SkyChip.vue
new file mode 100644
index 0000000..313448a
--- /dev/null
+++ b/frontend/src/ui/controls/SkyChip.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ui/controls/SkyField.test.ts b/frontend/src/ui/controls/SkyField.test.ts
new file mode 100644
index 0000000..35951c6
--- /dev/null
+++ b/frontend/src/ui/controls/SkyField.test.ts
@@ -0,0 +1,115 @@
+import { createSSRApp, h } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+import { describe, expect, it } from 'vitest'
+
+import SkyField from '@/ui/controls/SkyField.vue'
+
+describe('SkyField numeric constraints', () => {
+ it('forwards min, max, and step to the native input', async () => {
+ const app = createSSRApp(SkyField, {
+ ariaLabel: 'Primary frequency',
+ max: 999.9,
+ min: 0.1,
+ step: 0.1,
+ type: 'number',
+ })
+
+ const html = await renderToString(app)
+ const input = html.match(/]+>/)?.[0] ?? ''
+
+ expect(input).toContain('aria-label="Primary frequency"')
+ expect(input).toContain('max="999.9"')
+ expect(input).toContain('min="0.1"')
+ expect(input).toContain('step="0.1"')
+ })
+
+ it('exposes the inline settings layout without changing input semantics', async () => {
+ const app = createSSRApp(SkyField, {
+ label: 'Service number',
+ layout: 'inline',
+ modelValue: '231',
+ })
+
+ const html = await renderToString(app)
+
+ expect(html).toContain('sky-field--inline')
+ expect(html).toMatch(/
-
- {{ phone.t('Apps.settings.removeDevice') }}
- {{ phone.t('Apps.settings.removeDeviceBody') }}
-
-
-
-
-
- {{ phone.t('Common.cancel') }}
-
-
- {{ phone.t('Apps.settings.removeDevice') }}
-
-
-
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Common.cancel') }}
+
+
+
-
- {{ phone.t('Apps.settings.ejectSim') }}
- {{ phone.t('Apps.settings.ejectSimBody') }}
-
- {{
- phone.t('Common.cancel')
- }}
- {{
- phone.t('Apps.settings.ejectSim')
- }}
-
-
+
+
+
+
+
+
+ {{ phone.t('Common.cancel') }}
+
+
+ {{ phone.t('Apps.settings.removeDevice') }}
+
+
+
-
- {{ phone.t('Apps.settings.factoryReset') }}
- {{ phone.t('Apps.settings.factoryResetBody') }}
-
-
- {{ phone.t('Common.cancel') }}
-
-
- {{ phone.t('Common.reset') }}
-
-
-
+
+
+
+ {{ phone.t('Common.cancel') }}
+
+
+ {{ phone.t('Apps.settings.ejectSim') }}
+
+
+
-
- {{ accountToast }}
-
+
+
+
+ {{ phone.t('Common.cancel') }}
+
+
+ {{ phone.t('Common.reset') }}
+
+
+
+
+
+ {{ accountToast }}
+
+
From 2cf15c70be14a8f8d300dbeaecfd2ca5f96a6ff1 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Thu, 13 Aug 2026 03:10:19 +0200
Subject: [PATCH 51/63] ENH - migrate Radio to Sky UI
---
frontend/src/assets/img/app-icons/radio.svg | 15 -
frontend/src/assets/img/app-icons/radio.webp | Bin 0 -> 20858 bytes
frontend/src/config/apps.ts | 2 +-
frontend/src/stores/radio.test.ts | 279 ++++++++
frontend/src/stores/radio.ts | 23 +-
frontend/src/views/apps/RadioApp.vue | 678 +++++++++----------
6 files changed, 611 insertions(+), 386 deletions(-)
delete mode 100644 frontend/src/assets/img/app-icons/radio.svg
create mode 100644 frontend/src/assets/img/app-icons/radio.webp
create mode 100644 frontend/src/stores/radio.test.ts
diff --git a/frontend/src/assets/img/app-icons/radio.svg b/frontend/src/assets/img/app-icons/radio.svg
deleted file mode 100644
index 001fab2..0000000
--- a/frontend/src/assets/img/app-icons/radio.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
diff --git a/frontend/src/assets/img/app-icons/radio.webp b/frontend/src/assets/img/app-icons/radio.webp
new file mode 100644
index 0000000000000000000000000000000000000000..bbce3fad008dd7e9847f2cc1fc18cb89171c2b77
GIT binary patch
literal 20858
zcmV()K;OSoNk&F;Q2+o}MM6+kP&goFQ2+qYWC5K4DgXii0zN$+jYOg$p{67fX{f*l
ziDNUwtW)~4Dcv6|-FevYKr2d==m^_s>uS?rAKqJbYqe+Qq@?mMi=P$oZ{<(iU-&$+
z_pkY0Zr*$Qzx%)NKagKG|L}VM{>}dH{@=K7>&N`>?moL;vp)bob3H|V$$#DdVeaex
z)BjKU4|QMGKg@sT|7ZT!&R-wglT
zKjVJpdsct!_5lCO>H*YWAN@V?C($RxbaK+)YMP>a=kl-fe>MIr_gBDc+q?AO8|)YS
z&q80#|E1{v*iT>|=l|P(-1`;a^Y=gd9>AZ=f3bh!{`L7m^3(sX`)^6#SO4($8UBU+
z>;9*&N3gHnKV-k#(cPR?aL`6pKJ`fj2uOKvRxy!ylNPmBTVj-HCnV?+|DVwOwGxMd
zX-UJ%=Jb&29z`RYvFWP*JL^Dgc{B;g7#8sXlv^|@`hN(~*$lfP;p2=gEchXL#g28u
zWl2!^HbR#3Z@HYNJ1y$^{SuCSkx0CNT(P
zt>jYReeG=R)BgCdrA7<$`;jJj2=$t=Iq{ONW0`n~KZ^}tGxefcJ(A!KoZ^Iy+Hx`=
z0vyPL@5H`-@@+3qq)X3O3pA3YL0oN{Av0uW2HD{XA^H|haeCwb^Wb>2u-MQr!F@{#
zbxJGxuXM7JEkiR{VdYVZQ-+^Z$%0
zO^1IWu8lRu*6-oiVpga0(Nzk4-qW&r^JAw;R$W6`cRn&Jn}kni-IWydZq^ncSU+b%
ztpu*)RM=@R|LM7Z&Qh~vh3;iI&)p7X3P(zF
zU_H|CTY>x3<0OoO3c^i#S}+&ZYrs!bH_7i>jOB(0j6N_@jk
zJT%WosB_0cktXp0d42~V=l$Pk%gACGNXh5&*b(n5|@kf1Dx>tfE){u*qx1zdpfgZk7Rj1WRt78ckn^xp#2zT
zWJRg2l`}xsTXwQGkJlkFmjY}u)D-!}cBk_Yy!f(57wn0GmjBW%YLW2S|AYGClrS|p
z6^Qd|K~Bm-ACq}**2SA!2s9@|fw#?aC
zYEt`A*&L33KUAQ29+4kn{m{gZK>5i;DFY{NWtk)LE10073oa$*WIB87NkU*j6Z%62
z&q=-%3eKO4htrEXn|`a+?pQgEQTg3xv!|UtzZ!NP%O+W-Z1-1D8W!iD?f<424rCgO
z4G6t9w${|xYj@8$i(ty-1!9ZTKKk4z5bZ=TpWqtPM)aVmGaP<;8K=DROUJ@?JI-@Y
zy2K?6wEjLCeLzmM@+%$-36u=S*2$vO2Bou??0Hz#ml8W{I={ZxkvK}ozVcwWxs-6$
z9-{C%-q@Vvm5l4!({3diy!WPB#)?hYNAn~;hC0+H=8$K}@@>T%w3+SAcMuunDH!fF
zg?7?_9Z5CCfPnS~CStH_vS71N&2~V`HHwC#DW#pB+F#(`KSEL29XSY6UPZ`_X%V5M
z^sfy|pOEts^ZV!cM$tex*fUv67HO7sPT7$L86UJ_-@DZfC?AB4fXS%V+Zm%
zv(w4mfC3}i+OSz{bYc8@eKWwqrpW3IeKpUpt|1NCZq~jSAtgos@9D#%uLFQJ8eqTq
z4G%wDo+AQfz~>5y2!5KM37-S>wAI&WpC8y1xNrAQ7dEGjIo{uO%ao_Qg&zB$JLBW;
zOPm{C{&i$>%XP=X`+CzwoF#o+F{H%86Jj+(K?~cnMv(26TBEZeZlOiFSU0pkXsj+}
zrnHQ>yV9-bXKFuCR;9OIFTQshn=ZT?XInxz~{p+T%{H%L^eS(^rKHidZ|f-{dC$j`B?p^BlJbr_{ECAAjK7=Rb+BH+t70
zNG!kmzrZCdfG%l`$D{E3S~mTmNsT=~{3!kLjxIRyI1~C^JA9de2H$q^@?2;?apcDvRx?3omn`}6*b6G3@`W8@t@Ul6`=g?|2aPaws4Zg_01BY>|Nr;}0DD6ZZ_f4*|NsC0
z|NsC0HB@7ZB%3gMSTQi!HAKy^j)`V|&5Ge2(iKiHI|hypSgT~+*j{(ahF8aVO#QL{|NBq>
z|Nr=(K`@!I+IpvzxPWWSU!fFI6#23s?{-VmV?=DU&}KAS4?MSxn@Pz
z6XYi?y~mZOHQZe-Dz@Q5#|f-A)A-j(XP<9Hke24wh`tRUn#ikHw5Fg`4M??{NKl_hx|Vgvy!-)Q+2r)}bHCrgPZe^xCl)Oo&H!)_a{
zAxx%i5V;`Qx^)r0ejy~`V-}qI4_vaiO(mGFdz4@Fr)*)+{uECV5QUO$+3S4nfBcx)
z*Kkk#?EQTzJ!Ru60Qw&>g=@Y&9J-6*&!vtW<7Tw|=Ny>0nL={tWVDJs>ZXMz$Y&Cq
zv3iaWHqQcBd@V6&CfbhT&b#3e(c}TwG%LRaKUP2g{rq*wA(E=GE*I86|B-Sw7fdmx
zejRg>)*P~AgHpP_M1QURnU5A1`)alCcm2~A`NG=I+Ngn~o^HGD8ZMQ6E~{pFUytmB
zf798&E;;!B;Z33y2|pI%_F4{BwL7TQVznp20zZlT9sq3+T)I~%q#KOn`@s$t++`PuwH<`NkT+S3Y
zG;Wn}CC8HUb^Zp^x7>Q52L&)9l%r7g}wAP-;J{Xz?AxRtH^Hl>b
z6`qKxCZMT#lSj0+sNyY3{oN9=WbcowtRgajj7VVsPIcE=`LE^z@ox1q3;BO2s?Z
zr&&PJx2#2p4{4|~%xY^{gR5Q!K8#L8>?f&o40w(PFFguYw^sf*QwdAs
zt}9im`ii;G$f#w!HfbBW*gddu@*N&7dpGXnGect)+4`Z`Tr4v3xoK8l<;e8)V0ING
zi)e6?rfV-PjmQB|%u5^u*#Zu{T9s8s%Z6xQT6bc`#ig2>J1p_Z*;_I&6SN(`fLA2k
zZkUsV&2cXXMkRG6u$14Ob^L2>y|bT1vfu7-uPn8G0-Gv6uZECB5p&3c1^#{KutZ^-
z?S?=A0pBLo!CSS`0&U7VCK#X;rmOwyh8bNGqNT9yki^#Fq%Gkui(`w+n!%tzfAH85
z;g;yiuIGl`{K^eke-ylSUtZr$ypZa-Q3oW|scRBZx>#X_1px>^*5RGJX}?}#K!wsR
zY|W*S8w(ro6z-fj@!#
zj}8Y(5>9jRMqZKL)Qc_~dmJF9L_Xu|cAi|Vd?4WQ7E=}^oRMw>&%=WuB(l2|=S6al
z;M_;cgW^++s57VoP`2)%PdhHd(}5~EbsPcy=v)=G4nl9Vb^aS=E|cu)m$}eI{W00$
zE++kx6>dPn@qha>fQGt~DKbIz`;h`yk+2?{Um=oY2?zD?ala(!UV^1o3NsF9X=+@l
zf-(>OE_D*duq1cn1eSZLQ|yy^GJ19<&5gY`3eux9x;JMOh&feqxn_d6^j11W7bbT!
zMzH8w@f2Ql$!&!*yjBFk3J73f>g*lZYyE}zg5WD#wVm}aejei$d1jWExgOGm
zPf(wLmra;)I!!q@yt(uqAH|*7x(0hZ^i;zmpD~!!8|h&dwk`dBv$np1e*s;?)-ofd
za}!{v>`2~6FY_z<93ue#4G3zLXIN~X#T$Ex2G%RRI1!S~fhTEGEE0aq!%2D%O7W9d
zS>r~@R$$g$%+-$6`%RbPf@+&C%Uh&v3r6L{bxr}j7p#!da$)muY(ci=J5Mi;=VdUr
z`4teq^rXr)tCAgwIzX=hz)B|t+{)05n<=rIFc8)52ZWabNu$ikb>OSyNk35?Wme0y
zm&&vLFm{hCh?(fPWajWtxsQy7WCq4TE#N9k5Xl6
zwVLE~vn^g>ctxRac4dK&S5I($&2_JeL>I*wW!=K47db(+>-b5J>xy5)WVU0sPwVhK
zO71D~rn8r$ZUXU8`PAxQCdCaSZpkG)+d3^+M+!DVlvNb6ObOCc2zaIoC-}_S15QD5
z4A#LC#d=b2Uwnr8Udsxxr_4!kYgc6Kj+Pw{6MW>*%HtLPfsYx+<@mQRz)<4aE5!lK
zVnj>53AXG(Ey%61{!VjEyu&JR(mo`xJ@QKGk9pA$xxI%xrjNq^8tifxHeeW}S;s$5
z+R|h^4E_3XAL2B&W=+t3IEIckW4H|6K&W|x6-ZG2lg1%HN}HGr69r!EU-~$2p#?nx
ztgTKyY3rH~m3r-n!aAAjQ}*h8XX!G@B-t(CmYrr_Xb|p@A{;Hrf;V?SadellmJOD)
z>aTMsAe#x>^n&1!iaBf%K4HA|MI1lsX3#r`oxY3}?kxkC@0rdXot8vcdsdN(hm4DB
zr0QB!z=_{S}{5{V+fByhIzmp!4fMH>}3gvo0P_Bi{L9G+mDXds+bDQF3Lyz
zwE4_(5y!pT@11;h8>moaB%vFdhZ-zIb$>%OF;TW~GP%gfW6kpHZs;FQ7ba~oFU1g#
z6ecK-AFNL(wYuFxD1h;};wJfp67?dl){?xH^qjzZVj_1^!Yt5A%+G+)-Z27^;Lujf
zrHn7I;*?1p>9eWtQMF&`M_q^B6}F?epvILHGeW>G2BPA~#SO7^D*E3pO5wH&o{M&9vS>C^{Y@
z-c#s?F0E%Wv@FgEQdlqja$4kMj>bR1lu4&ylj~)8(w6z`fZ#T}O8B!J6|lbL|D2@J
z?5L?nOy>!Pr9ZSq>!f_mCP&HK9aE;~UqKVt^gO*E(oB_N1U=^5x
z$(*AKvPB=#*exvOTcttNgAl(is`ybZDKNSrnqX|gauI00Do$KL
z!X%U|x7*3h&-m9<@F)mU{J!74a}>_y79O5p@%iia2N3Qahp*4{6;F=kcpg^5vhPNX
zd{{lp7A^9+FCySnxGsR+L}jxWx+h|@zx+}%(u)Suz#OQps5gT4+flFIyEq?|ABz(-
zqY3Z0;G5r}et1e2N^9qHX^$_QB<*T@sQVDt*d~Ud5MA1LwrG7D$cV=9OrjJ15;*jZ)-Jx)CQI
zTbgJd6tdakw`PsGHc?A9Mj5Ynt&S9_G9@7-qIu!%!&1VPc@KsRn4}H}3wwgfKBo1>
zOFPZqOn16cE|%2_3FDRWuNw7=kgFj+1VF=K1*b$`_GbFh707**w&WfO7u*><-65zN
z{9u~9F9*goyE4Tc8RL2!6%g8~UQ9`L*Ml_^brYDVP~g=8e23T7xt6iux#LVN*^RY^
z1n8^vu|qR^D^KAzx$jJ4(j7Z4mwwn04)8VDS)P@A~b5;
zJ;s$vp5iR+ePG?%>>6s}Q~lc5z}s?FM>ksE
z`45Rxr7mkIZyyUmR$Jn9Ayhtf{S>%kfdL8^dkxIYP$B4C1FS>+spb;
zR>4}eW1;6wY|=_T4=d>TiNIEQ`VNJfEHf=B-nv_oQKIuyZNOAyl)_*U@1x
zZ{k+#)7dT0YqiNKK{|tjAM-I55{cJrZ*2YV-XMs|y*If23#}9JE49>~e)_okut*~0
zuuJ}!Hl;!Ie);s?btmt9YmuQU0QRCUb0BYH?!dxSMB|!6MUBPH(6pOdCfg$2d@4ILZr-xV{yO?90L
z=VpfZFAJ8cKSCSMOGNaojrAz(N78eN0kR~EoQ>D85fz5iG5+1OU&C)_
z0o>hv@|5^Y4atRQ?&=Sg1b1$iE(6)R@fYK^6WTuXq7`Rop54jK(NCI@lE>J@K
z$m0gen!e-sZu75&(K8BSdMCr>6}WEvBk9hEp))G*3_)RgbXujW&4uz#npVO^As1`+
z4?>Jm^>#v-rQC`;yUo{Y$LHIYL+0!`FIWypY3>2+UGz(?0wJ{1^@z|44mTWQUd2Ml
zLsNedp9znQ`NC}!od^#WxEiDTjQuY{5B?HE9Z@3dPlcmb14t&&xv7@oF(yGZL-pZ<
z+EuPhqONGO13f6
z7_ew(N~h3!h?wa^mc&5{S6hOjLpt{uy=uV7Bl7esY&JeH%*Z|M)|rs(dKZ7RLG~Qu)kAAr&=i?t;DX
zkj9>(Hh@O2ulImdV$yl#d4R^?fxC?ET3WgSqeQQws$8x^cIU+fkCT3kEQ><&1ZaYZ
ze#2S_C=xnCK3GTyT$6xs?QhIezmD+Fo^Dd>3hM6se%F3-m)%yrehgih;27cy+>zW_
zgcI9tCjZg7@#7ISw?IeOd@l&|3v5yR`b_V^<=)>{J>nqd^Tn9&2HJ%2-fW?Zd5
z*FU6sAt!)i(b$mu)kWKiLI_UDi8x!9`**D!;%%+36}qGDx{{%;ruooomZ}WIB{R_g
z(FB}1py#8`u|rPoAz;}rs{yw4EVFSH3N<3otBmdBvF^!GBn&;ygq?yzwZLI@smnIe
z1cuTTPsKuy!@uA&)ve$mvB!K{;2zNM;~3Z~)LAG0i9;cai*M5;R!|PX#}rgHi5NpR
z6geaJAx)N3)dOjox`yXD)8-zEb#!)f;Md=|cKszTGsXNWv>`{PI-{5>M9FG*nUzh;f4$cXb6X|gdvCy{DwsM!9QFSz2F~Ztk8gg@
z98W!DCw@a&J3-sa^%w&*&YVFhf?}
z(RFaZY8>M*wQWyMqjIjpBxrfshBp01PqlHGrq-3=G9@K^61JvFt5u&AweXoTZ-j)O
zhjhaGflWa0@VGNW)Vj`uv~*nls0tNtd(Iz7STBl_O8bEExbSyYw#nuGjBUZiI5Gf3C6DJ_eskr<
zuP$K1Tp~)(b0i~Tdh3d%>3&lo4I<3vHcIA$*GADrZ}Cg}^SV1-7Btd1SjAA>)WAe|
z&UNV9?x{I^F}|GxN_+?xs+&M;5gM$u+rl;Daf!M_$Kvm
zed-N-VQV<=@7C9`p1d&{88f}Nxz@YBrqM8m_v()o(-FboMXW~zF)$z*;RFtJY*idU
z>-Pw_R%^`%@Az(IXp(x(_Bk(00JSU4BiShs?0-1*8)jznr8RU
z^?od9al2Q*;vq3Ijj;8-**h;NK}U^e^LaQ4jM$x~1R-c7zk^h%thruNp}6-6$i&O&
z?T87N{LGU2)J5eG{0Z&823O@3d^fm&yQ=Glc?sy<{yP$_qO|S!-Fvlb+_pfl#T}Ns
zk{;18nNO8!RjjHXpV9o*0FY;cJv*IigUlUbp)V~fPyqbN=vvpB0KOkGorj98HGBn&
zx4tI*1E2JSUQG-kk*Xd?=xvRemiTjGqbud_{1`Bdel35mw4jK;8+I~UXAXRjli!QI(ALd5xeZOP(@igloTXf)0q+Qg1aUDN
z)Il5!$sQ~V-JPsgujWpy%ybWVyx%S0$qa9NY)=N_Q0gRgI_4-DHVI>niVZ6)Kp9?_
zqMo7~;KJ$0Ua84j)*p=!E+;Q{GUc|zv0Wz8opY}IAHZY7u8;1&=cWhHQ@^MnS(*Y1
zSOL$7JuqTVQ@~WM)uPzz@hTf_mkprr(l`;tE7w?#ZO|*m(L50icn_GFpVDNO-PG%+
zGui+NbbE5B(Gj7>YS_YV12G>9BK~*7qor-3*7=@r!|Vy8rXTa~h+yWwj&3$p9jA}D
zV9yrR$0!XFEtN`0fKRcx_6Fxg__dw9ssiHur>{25TnhK6HmO!X?JCGf$tM2^o8_ll
z^=OJ`_bWJFK6#|+zi(xka2uPOc!rx025P~mf0oi3qKR^l?U9ufaAKW{-;hh00u9TR
z_D3KtbHw{wOv#xR5W~)vC
zsAS1(&Pw|z+{pTPWken(fwK$`%oXT(xGF{;(`U>1Fy^Ww5NS!s{m`%+N&pkeS0x
zU9)tbU?ke4%l1Cq3xEF$qYfo9n#a)a(!O&O&ETT$gqm(=za5-curBNhkV6;5D1}!V
zg=!gS$pOn3t~xjfoa!5+7?tm)&s^S+Nm|V;C5f!l9_rs8
zADp_Y37SivKV?N-?Z2a6BCuwMkr#wud(+R@X6|e*%b6R=p=G8hn-q}v2MD`%@CJz<
zb1#tHPyep#_;Bv4VU^isJr-MD=H+La1lw}t?>Ul+XqEV}q>}f^c`j4?Y0pYD{`cem
zm1Wd|Ni3qZ1?(e?`2!7r);X4HF^D`nWPc?)~
ze^J+3{@o-O>*vaw8OC3zl{Ob+gY&YeF-9L**mWF(*A$93%p-|5A+XGxJ?Tx{0AnMi
z29e2{z=$mq_x^n6UAY|&$WCRA9}*|d3^v$
zQ*j{tYV*|h*z5!7uXH3Rw~lihMb0Ne+wUO&p!j-QmesJ<7^pHqaR61&J+$maVgx(b
z#a_5}tmCQgAqjh-G)#(X6fHTxV%=+h`5-yl8(&f60ycO|SJrsRguk9b6@8WK{s_D6
zhbOTLJVrTa&qJBsujNnz^MSmCO7<2%vQ>q}Xzbd`;
zkOqyF*5NzD2^oKv$E=Bd&|$(8t}Q$Q2l6_tIOgT!MeR#)yC%KCn<*$11&30o8_E20
zs9AFlg#$Rkj7;~RV`~*jJ~)PTR>I{_AZaiAKN})ourU~SSK*Es|zN2|y6~
zpmtGvD(~x~Ec6#7cnOp0qXOubviyD7nEv!WXNy_V=(<3ZC)Gau;vYaj>Ovx5L@OPM
z%iL4EAkOl)7iXOo_&K(API1OtG*@9jC7_gQh{(%WWnN*Hx#3uaj5TBqK0p5jt_IAe
z3{Lo{3M(j58Vlrw3{S$_t4Ni#stMjp8$Ka=`_=OJMe1=5!Z*Jo+Zo??3OYmW{b<9;
zQ2)1q)U(ttXe6Lpn0SbatL}-&7Cb9Xd);v5IPMjFHn@wAqCSA{$2gQNEo-QO!2h=
z2G@O47YLjU-zAxXY4(IiC3_IBduJBXu%lDX%2cHZuzQ||U-#P$ucEmZeZW#{E`xKy
z3HO4JZ#VdlLHTK247U~s#9ici=R(=C?I>4bRrQ$)TO}eC4@ftz1!Yg0!w7BJhSyD`
znQaBx<*u&C6m2c(O)qby$j<}YxPb2(6Rf$Q75}N{BixaX
zHcxx)gV{-+0QX@mNW|=)4(N9`)SU0w~5RxI|fJB~|vl~@`
z)u!I=@X!2sXy?2JbTXyS;VKJEdWMf(P5dye5>Zf4t@5(L2!mk7XL?pd>aSrpO(Lc8
zN3&ADd6j14moJVzPRh=P1vk(jY2*dm_)gmZmPCkFsE6&Jsog<$M1`Bz=KaQHL=L^-O
z&%Fua&?hja9bOeq*8mFP2VZ(1Mcfv&lELeJE^k(#(|kxr5@Ne`#eLkLfnJ|47Hd%D
zd`>%L>xoaf$&J|7X1lpUa|G*$Ky;WDTJV%Z+;JjR6sNuPc`3kR+`XSsN01f|=7-HO
z&*7|z>TV$&cIr&oxcI34dZUx@{i8FEtxo1gFG#JLtaiRo
zbq}L33mY&mQ%PQlr7Q%VY4;YyfsvVFI6LLFV2>u$l)hxS9LgI2Y-ce9A;dWYt(%?C
zicd640^JK73Z7(iOyl^E5YIArCS|(PHeSu_N;(R|_)nBwkPeZE}X6KAkHS1s4xHF)sR0?gbnZ
zbOUYEW5Wxeg+H>KX}5bg)}v=n^^b;!^&j`L(vn|}VvzTk!dTbC@$;O*oxKejMH4-u
zvI(NG$0R6}D_`u+ePpmNK8@mAyb4#3x&e;$g-p&NtQR`s-->{611OE=3jERE70>(S
zoAXvDOEM7L>K3>!bMEI;P4)73C*zu>Q@Zpxbw}8-C6UhCA-ZvfVwN(E$NnTq6l&I(TJ9Eo`lfjwF-@VWOO|
zmWwSS0RVcRt@F!Q;)WT2c&$qSeBTv00ad#=iw&ji8VcI;J-2CtL+a0}4%znT_AmhL!a6=gGkneWs7I
zO{6h`CPue>n!s!pba1dX8N=`vBs#J`lcgr+fYK#=F~|*48ZP|N1Y$bzuh0#AC@Ns|
zQ0E40_nHqby7N3m)-(T*0B!w;KG5(ZHIQmGtX-6ch4dDB?JJKrT|ndM_L8y`=wCO|jy
z)6l8@ih|(+NU3*9Ecvn_d5gu6=5ite@VpF214>T`;Jst&W7iI_{T<3q=7;8ruFACWdj
zSR?E0Tk^mL@E?Xw=z@yq)Heb~+?@zhtbr3LT%V!=!3@)FTHqYbxfEKT*!sEy9z)RA
z`kmuA7*0z=>QRpWR4i@chNoSzu3u%5qItA}BmWqZuZLYn#M>R5-tVuEwY*
z9E=4l57g=Ae5PmrN)D^{<;DXj>1po}S`BJF))*^+Js>LT2}QCCw9(0bOJL*TL?8Yf{Ce?MnGBU+_cvYGobsk
zSo}#(JUDsCE=Mb-1u7XqS9a^
z;5pWYQ;oR^;K?}F*Tc{Fb+Ccve!)8*3l
z2I;?JB7AR5+{N?cphc{+;MG@%`j*Boy=X~uZhv#b-GLQ-{a)nk2Z_U&cfSx
zhmb8c4DzbjepT+E`?K-8J{@JSU%4QJvXn$``_03XIuP~sGua5ijs*RL^OK^l-MWN?
zbwDXbRtdvsY4FaRkuF&lA4SB@A*5Tko1n($sz-~#l<38a5kv##2!7g~j!|2jxL;082C=4T067&K?*++NUj8k8W1f%iElS4`9y%XD|xdm2jx
zIw+4>51NWIID)RFBQSUkHA2qDNPSbAB*YiKY;}CG(SC(oq_?~7q-GL--78+@7%9gw
z+TAZ$e%36Tzp_E`*BViYZ0JOPoTOPj+NK==eM1AL%qX-o_#{E!$D6#JHkL1AtvgCaeH5sX_%u65@O~~DJUo@~JmR
zX*z^$N>S#VBwS&JeVu?o-4g8Zy)aO$Vq-u><2E>-X?YrACV)!nNtkj`;!nD;$w+yd
z5DXzIY09;FtWRS!(MJRG(w_tt`ysNlQ#anhY+6tA46!^A-LAIZ+lb0)^x_(8z6v&f
z<6djBJyI~mlmY{^(4my|DMeaBCi?;eY3P9vvM!X6lV?$}yo=tPLz#
zNDE2{aw9OXX>7Atjs1v!(I|!o>%2SCgP9wu)sP&G%;NUG65#bmJ)Q%wn53^f6kN?vPVtMr+o==D
z@R@bZFk5Ug4#1>DiA;5>nw%>L)NQ>PaKdED(*-jR5X*s4cED%jj5A#WIioHW`Q|1Q
zC9{WaO5`G50ZRg8l-+3o=7kVuva1thvWN}MxTqc-s}>bd>AKrC7-iuQ%0@bu{_fJT
zgLoLo(f!NNCqDPQGbtc?LTW(sc@J|**O^V(3
zU*V~Rt^IeXYSnn1ekg^i%05c!KuP7Ru~EM(!EA+9yPxij<`lTo@p~q*#_$`+R9q|O
zno<{=_hi^DM){j6Boaqa(y<#!AU1Mb-wtOp2wog%v(5ff1do9oFe!}?=K~5b4l8Tf
zul4gpwQP0DJTBj6W89z7x=Oeh3TEDgBf)C)uJ#vYO0qqxh6K_%S)6&f^cOpc=CdF2
z1npAx-GRK@8dY(ZAh-gz8j^$4A~+$3?f_SbI)h80;Pv1ZBL(q2C54_3cWXE+bVM`M
z-Ji&hAIxpN0=`*|_QbL{JuG|GPhx|Q!F5zE{B;(A(Q9T3NM>brBO5V1RclE9fHIk=
z!1yq~H$8!?(y%}_5{$!40bg}x0}_!)_oY&1k$MH-3ox%U7IMv2^{|
zW)mEX^C$}Ap3yxUsDd;~3J(=uj6XEj88nL(E;0X;__BPEQJ#`&%qZ}xXpY9EgkW$*
z38CC2{tQf}S}3}1;q}Jbi)TIR-!ZS7>p>o~EW(9L^dHRYp38^untwZ%?>7jjDSf+*ox8=!xsUG{*DBD9^o^uTqj3m5f757@STdqKPCWO;s47_gD#hAwFvE_SltT{5Y479dWXD8Vi@3{_494ku1BU09w>G7VbZK|qeNTLlFXpMUD)wTEa
zhkG-7mis|)E~45W=RZ4bw?3Y7rgNgs`0CH_!OK2h?y9wcJG6bhFb-Y{;Zir@Eca0C
zwq;fv*`JOpqeMc$RYkQxA7lAT!{(5W4nDA75jA|vu|*Xz
ztb8IVZI)DArT*$4a<+aHZ(Z3ex>@Se_Y3JrEP)Jp=-2aqcmfNDK~*0c{7*Sx8Qn(E
zxxSx85CT6l+#PCXkRJI55Zi6RgZlzUWVqeNmD!b9`n6a9iX-gjXFMw`?TTZEKxed@
zg=`}gr*43IXJ|(L(qCPDE5de#2r~bbBBquO;c*8*Y|5TZEh-)l7L0z=+AlG2{zCzB
zTJ56k-eS3gLhfxIYcB9{3ddEAluzcXUy+2zm{yByk@V87jwGO`L~l+B)D6F<%Y@@3
zW*wO4F{Gg>9YjguLhtfc8xzmdHK=tF6EQ%veApcZ`ah^+B3R%q2^FzjBQ5O~VdLx-
z9c(>6zQJ_-UYLn~aOqeA?W0-CFj6^|`O8)1{Bz9L?Qj#)++3G8uiV=$zA1BVA%_oc
z6G@TRfeJWwgKEu%f2*^+&!LYcHap!BGk#24*3P1Oh7;-c37Dv2bT@0@ng(@MSq4sA
zoHcYkYXK9JHnooId|lE@p&YxauOS$@SNxu8p~Pxcx4=URQ0FYXg|Gkmb>TX#fmG1Z?Lk7Rh{`P3y<0
z4ic~gy2???yLo!@lAKV1HthI%qJn9dsZ2@ogm=O_1R$1xcvLzC3@W1=KDR)kC5DnV
zd(wESy$I@myLkv!<`h>SA_zcH
z7@2t~11c}o(aIdXKR4-E(+JKvB0uL8t{a#y7;GoS=z!dWG)lT3!#B+!3_)p0Zv3cK
zN*pdIGn8J~ITrg+2=47-?35T{#6Nx&!`tl|0D4#0zjQMrEa<|g;nJf6v*9~c
zs))Am&89u&jUxb8%Iwx7CE*Gs>kW~E4)CvKa$6AtyM+X~^#$uCu9B#Korx7TJM#)o
zsud586GWA3Zy4c3drm+*7iX%H-Z7rh4OfwG7LbDCGvNsp%b}R2{V|hLi=G<#H44GO
zdfH!@6jkN|I2qm5-1{k_Nlb@xZVG0C#9`jeCLX>(jFUlaEWDAVk;D2Eo8dv3NMv-o
ze&2qu3Eq0T>iiDVA0~-r$)!Nd=)Pr7$JziCr6O6Cw~B9_D(V)DXn|6TsL@~C!5NDZ
z@B(%vbevxRtE8=8GHnk#bvG^<^MN+MjXUz20S#R95pa3^?VA>R@{qE55rjn6q;C
z^IPkI#CeR8S4({vxdk1E_^w*gzw3hhGT6?;`^ropq>fDPGgZ(e@PISnGEeZjeP+Wa
zXV8t|12dY!-J2O%jx)oWmsC!4EpXO*+Sr1Fo;SVnobA($r*qiY?m#(NgSH@RGd|O44R0-Lw{&^o%K#L1}saxs4CA{
z2U>{W0>4s-gD&0`)A9qNLq58Ejj=W)mYd^9Bnavc5x}my`Bm4A#_JmaS`nr0G|M@r
zx=bc0foygs%S{M)&t{enhJ=2^vQ?*$JZ}bX)Yok&C#I2-tRUvk4r@5X-n9unX8UM8
z$QB}HCOUpRW4@={whrUiUdnb1B?YYaHx8*iId)i-5e?>Fr=%%8T^W3inlXJkQ^Rf&
zUPiIJPZj<5*=-ZFa)qL#Om4MTinB_n5i|G=%|g`l;NiA**>2fzuVJaX9V3Ud(^f|d
zT63#Z2EL)4vnxK}cNDvdeI$#d|#ap&J
zSA-^q_O<~6KVHcyE1I+OAD}pNE%u_r*#t2aY%7$(uyvk8#9DG@emO5LSu&D>dRuas
z1{+Upsb4*fVF)5G>ttXX;%r#^9WnT|+fRT4KghgeRVZmu!=8t*R6I;WC$EFL-8?}k
zH160jECQeBYNcvhySEPHNM@*MYC*H2dn0c2d_1FNLz>QGb^I`V@G8#>JCYN>p}|sx
z0T<4|Fv4GH5kwFYbCL~lMP_E5P=bz(*9<3=;;!V|-)_0*^SP($`w~{Av@J^weS1ba
zl}HaEf#RFEUX9_oL2N)3-6$1144tSsVF6)w4(1j&^HGN7o9AJK{?y^U)nTHW9Vy2D
z=RS`*KS*BYL`NB?&>)rY3ltR~za)M$#ioV8dkiAc0B$%5VfwzKXp570*Y^K+!i2t)
zOJ$0T7Bg6Otv}TEtiCx>`E6Yv(_*ulp>9&$42vDx*ga|me|^Ix{fcu8MU
zh(}$z>O
zLBbrL0P4syZ+dO5N5IAT29j>TEH{dmPO`4f^T?EimQb@?InW2r%zp!@IW(DnJLvy&^m}I+Xz)+yvlY01iCr43)?SVD4
zCsN6n7n}BT0H@)`uqDg@*;HsSy<;1NkT%61Tp98ptKwQnflY?y=lZAYo7BOf;RZF&
zo@mNghJX>uv<;J`R?^^tO6Qsr%FgJcPJ**DbzK3FU-}Y<=
zKw_cvSeeG;wclpj$f&K+tr0wD>n{j}umJ^qi`AdKK2HyKz>vR{o;2@$2;*qdXiy#c
zxZ?(?a-zKf++E=6jftdbtO_hfN106h!Ad2Zggl3{zi6xwcEam
ztpdYhyxY2BwifNuKUZ=H^%m5>4YtacVw{#{dacp1stVB2loY^>W+N30-yTD78ou9
zTTyO?$GYP^lO+_|9)D;VU22dVz6$WSlrd|6m%s@_^2ue=gNlmvD%H0
z0D7LWYSH5r%N~N@xb)oMp3!7sTN=~PwW@Y76gEZ>uPHpWo&8xN{~yoZO$_JTenA<;
z{HG8+qW&r#(a!ixblc1DutHU;rEXY3OtaA&4z1oaS~RSjV{rb6!OPtEl>q~S9pgf&
z*l2_*{5t;`G=`Q-j-2<&Z4+$9&iKx|H8~pQYKhc|<(EksNL~6IyVEKe4Zz%5)~jT-
z1e@`!RNUIw>7@gH(ss&8eTYj?ScnlRMNAE*GuVPEAWjuv|3h%#HBO#Hkw32;>G_A>
z{+Y$G+2QI_?%fbv2fSShs=ZfdAn9Mm!ZC#$&q$=`{>(hwWWRnpYXg5>m>&egOy<+#
zVQ7zGOq42*BAUat`u^*v<6pWf>A!iMzYh~d_X>axW&iIt=MVc$K1sOIG4Zv)Si>?}
z9u20(a_k7oN*dOCiO$%l1>k6*zqwViF4ueo(2ZHdMcS7GH@Vt`LZ57_Rw$7n@M2LJp;GN4
zV;4i3sg34#hu`L3Va4bS<^LaNpbfjZ*Ewsph>v+?a3c
zM`}}HMuDWjOHTWgD3mcR3gu~F*u>e*VPYBw#dmqdRa>+OGznTC1fFmXhufRTzuC3E
zh;)sFuMDPF#x~^V){dDe3KU6#n)^2X1G$jWP~kC?n>ITQ+qmr`yoKp5X=6L^$h=ZU
zbU%{k6Y4S%H)jlL(g`jS^h|fRZ|vKaV3%b64t}to-~G#P+ETTXAaepxDylQ^@TGqW
zA?|b;iE!eF2J|hgb!7)Q)imNU=pdS$`~5w6ZX|?h59?52mvtzDQta9p=j)~ePM(bs
zL@Ix@4rW8Pdw^}}cPc2){8!kv`Y0XA67RqU%|602@SvD_rYN
z+{us)md3ib3#g*gZkz_kp}8&rOht2%e#b@H$%lN-m%i_T4T(L
zL*c29)wZho&4WX8op3cPD_e&@1igH%D^L2cq3!DZ#h$>67oorO*|3xd>Y8X}MQblpnvKY5A@^nb1azsr@>9Ll42$2kaUXmZ=mBbr!}H>Lz9
zqSL+2J@qL+2q%*3rU7sgLI#K&R0IQ>Q->LJfmnyCnNnt4E^<7vL>Cqe(PQH6@)>#&
zsx^2D`;cTr?jf>pq|}3FOuy|(*yONYz=)}M&+4>5zr|HjE3aJj%p;zAKaJbuxh9`z
z6%~qF*g#cJ_RzI|nq0;n2JxPyZRMn2pr=L=-NaFGNcp7InbI8%kYO@I`jRku7fm&kIyIo>gLL)VI0E&U9r9OTo)VNl;PVpd=<
z?K6ZkoJUsaHhl5YN7`^ZLPv*h3wFu%EEL;oc@)}}uM9u3uW_Qa-={MeVd%c&vc%W%*x1V~1XIE|0{y<_fP~1+Auq5;y1SB68zQU6M_iw<~#$68veG->3U8GCj
z?IyJuURA3ge3si_k?VK@M47F*=}mT2&h9aXeD2i)zq_s`ut-dLTF+i@x{8Wb;{mWV+-OdzPjj%!Hy*tAQ4bdF-v(P==d8&skksPW9Vy5)niA|JfKCyAo&>v
zv_i15p~yyZM8B(c#NPL{0GXd_wY^nJBQfgMBVuHdJKx0ioR($lC>s0=GL#Bs6*ES#
z-z(MF#7$}$bPH<}WEw*Tc_vW6X#iiSx8bJyArPD+e_S(AnDFpD9$&wxO}ktywfI!H
zRBNlr3o7HPUkM!CF(?w{dYlU@5vyL*B7g6}oU=4rkn5Ry8GN1z*r}iKEaoqcRD6Wv
zH=k3`-&yi)W@ac
zq1~7^qgx>EH8b4RT<>2{a0SQ;f?327_bk&tY;$&SjnjoyE*9uD;U7o&3V0>Otj8r|
zgD&6lzxms48$0TsFiey9ZgmNz)FG;}KyeSEA#7@GpNBDFWbYb*`31kO{5xTx!m}
zTQ>j_A&G&6TVL2ZbW(!T8ck;B0MZ99{b3Z}5I{IZ9aiAdCyB^8qQAL{d$d6Q-8*Od
zIPA4h2u~)6qqJ&g5A=7d}@~t
zYCvVVcJAC)d@%?&AZKSa_LO9;1e;3K4vfmdiishqGci8_ibI^HqQ*2Ue>$8Vl8u62
zA7d6O3KCBcE&*<(so*!Nn4#Rf^kU?_VcaI$`ktRcp$vM~)4EoK^h0^&z$
zsS{h)Y!KL)v#;rmyUuq_A9u!RV+r>m9lu-!P!;in@qWs1*8s8Z5+M@+yHxg?0n}r*
zr-Hp_=;A2K3q;&q+BH2I3Cj%BXEAH@J}AAk&2nn$MkAQv|54^~mVmL#$3p5qz?}qx
zVy?A3tj34oU`>g;cK|?=$c>Z(j(1!zY9E0j!1dVsWeo$M(?bDPrv4xC)rb1_s=k5}
zhmOTrB6)Z~=&l-J!+{DiAfRaPK)ncfyu{GxWO4vP&{Z#n`796%DGFlwuEXbpxNwz9
zKn?a$!yWc;G3Ig`vzLGx)FHjbl70e`0Rn4`Dj_rcRW&QIf1ExkdDenRXq-gE=Fokm
zmYwUXb?qRNxj~t;c`T5tU@rL^3{QHu1t6`)xucst%Eu9Asd-|QTv2bV`WA1Dqn?M~
zpBgNg-XCbP$mJ(nuNk&Bw!q>?6SCYWD*W<2;y$W2Z%a4$n%#gMR=1ipXP&-~ndw0;+YAOJ9MUa4A0TI^=4GTh>MmJGLK&_2f9@YPSIV{C&oqHKN#+g*_!WK+o`jeM2
VYvGVG%Ctw&?_s6$Lq%=s002J%yiEWA
literal 0
HcmV?d00001
diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts
index 4052fd4..9cd0a33 100644
--- a/frontend/src/config/apps.ts
+++ b/frontend/src/config/apps.ts
@@ -47,7 +47,7 @@ import mapIcon from '@/assets/img/app-icons/map.webp'
import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
-import radioIcon from '@/assets/img/app-icons/radio.svg'
+import radioIcon from '@/assets/img/app-icons/radio.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg'
diff --git a/frontend/src/stores/radio.test.ts b/frontend/src/stores/radio.test.ts
new file mode 100644
index 0000000..8af3358
--- /dev/null
+++ b/frontend/src/stores/radio.test.ts
@@ -0,0 +1,279 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useRadioStore } from '@/stores/radio'
+import type { RadioData } from '@/types/radio'
+import { nuiCall } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
+
+const mockNuiCall = vi.mocked(nuiCall)
+const radioData: RadioData = {
+ badge: '231',
+ badgeEnabled: true,
+ badgeMaxLength: 8,
+ connected: false,
+ displayName: 'Unit 21',
+ displayNameAllowed: true,
+ displayNameEnabled: true,
+ displayNameMaxLength: 32,
+ frequency: 0,
+ frequencyMax: 999.9,
+ frequencyMin: 0.1,
+ frequencyStep: 0.1,
+ history: [{ primary: 120.5, secondary: 130.7 }],
+ members: [],
+ provider: 'yaca',
+ secondaryFrequency: 0,
+ secondarySupported: true,
+ settings: { autoRejoin: false, notifications: true },
+ volume: 50,
+}
+
+describe('radio store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockNuiCall.mockReset()
+ })
+
+ it('hydrates the complete server-authoritative radio state', async () => {
+ mockNuiCall.mockResolvedValueOnce({ data: radioData, success: true })
+ const radio = useRadioStore()
+
+ await radio.load()
+
+ expect(radio.data).toMatchObject(radioData)
+ expect(radio.error).toBe('')
+ expect(mockNuiCall).toHaveBeenCalledWith('radio:get')
+ })
+
+ it('sends both selected channels and applies the accepted connection', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ data: {
+ connected: true,
+ frequency: 120.5,
+ secondaryFrequency: 130.7,
+ },
+ success: true,
+ })
+ const radio = useRadioStore()
+
+ expect(await radio.connect(120.5, 130.7)).toBe(true)
+ expect(radio.data.connected).toBe(true)
+ expect(mockNuiCall).toHaveBeenCalledWith('radio:connect', {
+ frequency: 120.5,
+ secondaryFrequency: 130.7,
+ })
+ })
+
+ it('keeps the current connection when disconnect is rejected', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ error: 'voice_unavailable',
+ success: false,
+ })
+ const radio = useRadioStore()
+ radio.data.connected = true
+ radio.data.frequency = 120.5
+
+ await radio.disconnect()
+
+ expect(radio.data.connected).toBe(true)
+ expect(radio.data.frequency).toBe(120.5)
+ expect(radio.error).toBe('voice_unavailable')
+ expect(radio.isLoading).toBe(false)
+ })
+
+ it('rolls an optimistic setting change back after server rejection', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ error: 'invalid_setting',
+ success: false,
+ })
+ const radio = useRadioStore()
+
+ await radio.saveSetting('autoRejoin', true)
+
+ expect(radio.data.settings.autoRejoin).toBe(false)
+ expect(radio.error).toBe('invalid_setting')
+ })
+
+ it('ignores a stale volume response that arrives after the newest value', async () => {
+ let resolveFirst!: (value: {
+ data: { volume: number }
+ success: true
+ }) => void
+ let resolveSecond!: (value: {
+ data: { volume: number }
+ success: true
+ }) => void
+ mockNuiCall
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve
+ }),
+ )
+ const radio = useRadioStore()
+
+ const first = radio.setVolume(25)
+ const second = radio.setVolume(75)
+ resolveSecond({ data: { volume: 75 }, success: true })
+ await second
+ resolveFirst({ data: { volume: 25 }, success: true })
+ await first
+
+ expect(radio.data.volume).toBe(75)
+ })
+
+ it('applies canonical profile values accepted by the server', async () => {
+ mockNuiCall
+ .mockResolvedValueOnce({ data: { badge: 'A_1' }, success: true })
+ .mockResolvedValueOnce({
+ data: { displayName: 'Unit Seven' },
+ success: true,
+ })
+ const radio = useRadioStore()
+
+ expect(await radio.saveBadge('a_1')).toBe(true)
+ expect(await radio.saveDisplayName(' Unit Seven ')).toBe(true)
+
+ expect(radio.data.badge).toBe('A_1')
+ expect(radio.data.displayName).toBe('Unit Seven')
+ expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'radio:save-badge', {
+ badge: 'a_1',
+ })
+ expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'radio:save-display-name', {
+ displayName: ' Unit Seven ',
+ })
+ })
+
+ it('keeps the authoritative profile value when saving is rejected', async () => {
+ mockNuiCall.mockResolvedValueOnce({
+ error: 'rate_limited',
+ success: false,
+ })
+ const radio = useRadioStore()
+ radio.data.badge = '231'
+
+ expect(await radio.saveBadge('232')).toBe(false)
+
+ expect(radio.data.badge).toBe('231')
+ expect(radio.error).toBe('rate_limited')
+ })
+
+ it('ignores stale badge responses after a newer save', async () => {
+ let resolveFirst!: (value: {
+ data: { badge: string }
+ success: true
+ }) => void
+ let resolveSecond!: (value: {
+ data: { badge: string }
+ success: true
+ }) => void
+ mockNuiCall
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve
+ }),
+ )
+ const radio = useRadioStore()
+
+ const first = radio.saveBadge('231')
+ const second = radio.saveBadge('232')
+ resolveSecond({ data: { badge: '232' }, success: true })
+ await second
+ resolveFirst({ data: { badge: '231' }, success: true })
+ await first
+
+ expect(radio.data.badge).toBe('232')
+ })
+
+ it('ignores stale display-name responses after a newer save', async () => {
+ let resolveFirst!: (value: {
+ data: { displayName: string }
+ success: true
+ }) => void
+ let resolveSecond!: (value: {
+ data: { displayName: string }
+ success: true
+ }) => void
+ mockNuiCall
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve
+ }),
+ )
+ const radio = useRadioStore()
+
+ const first = radio.saveDisplayName('Unit One')
+ const second = radio.saveDisplayName('Unit Two')
+ resolveSecond({ data: { displayName: 'Unit Two' }, success: true })
+ await second
+ resolveFirst({ data: { displayName: 'Unit One' }, success: true })
+ await first
+
+ expect(radio.data.displayName).toBe('Unit Two')
+ })
+
+ it('merges setting responses per key without clobbering another toggle', async () => {
+ let resolveAutoRejoin!: (value: {
+ data: RadioData['settings']
+ success: true
+ }) => void
+ let resolveNotifications!: (value: {
+ data: RadioData['settings']
+ success: true
+ }) => void
+ mockNuiCall
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveAutoRejoin = resolve
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveNotifications = resolve
+ }),
+ )
+ const radio = useRadioStore()
+
+ const autoRejoin = radio.saveSetting('autoRejoin', true)
+ const notifications = radio.saveSetting('notifications', true)
+ resolveNotifications({
+ data: { autoRejoin: false, notifications: true },
+ success: true,
+ })
+ await notifications
+ resolveAutoRejoin({
+ data: { autoRejoin: true, notifications: false },
+ success: true,
+ })
+ await autoRejoin
+
+ expect(radio.data.settings).toEqual({
+ autoRejoin: true,
+ notifications: true,
+ })
+ })
+})
diff --git a/frontend/src/stores/radio.ts b/frontend/src/stores/radio.ts
index 6f1fe3d..b259535 100644
--- a/frontend/src/stores/radio.ts
+++ b/frontend/src/stores/radio.ts
@@ -30,6 +30,13 @@ export const useRadioStore = defineStore('radio', () => {
const data = reactive(structuredClone(defaults))
const error = ref('')
const isLoading = ref(false)
+ const settingRequestIds: Record = {
+ autoRejoin: 0,
+ notifications: 0,
+ }
+ let badgeRequestId = 0
+ let displayNameRequestId = 0
+ let volumeRequestId = 0
function apply(next: Partial): void {
Object.assign(data, next)
@@ -61,10 +68,12 @@ export const useRadioStore = defineStore('radio', () => {
}
async function disconnect(): Promise {
+ isLoading.value = true
error.value = ''
const response = await nuiCall('radio:disconnect')
if (!response.success) {
error.value = response.error ?? 'request_failed'
+ isLoading.value = false
return
}
apply({
@@ -73,13 +82,16 @@ export const useRadioStore = defineStore('radio', () => {
members: [],
secondaryFrequency: 0,
})
+ isLoading.value = false
}
async function setVolume(volume: number): Promise {
+ const requestId = ++volumeRequestId
data.volume = volume
const response = await nuiCall<{ volume: number }>('radio:set-volume', {
volume,
})
+ if (requestId !== volumeRequestId) return
if (response.success && response.data) data.volume = response.data.volume
}
@@ -87,35 +99,42 @@ export const useRadioStore = defineStore('radio', () => {
key: keyof RadioSettings,
value: boolean,
): Promise {
+ const requestId = ++settingRequestIds[key]
const previous = data.settings[key]
data.settings[key] = value
const response = await nuiCall('radio:save-settings', {
key,
value,
})
- if (response.success && response.data) data.settings = response.data
- else {
+ if (requestId !== settingRequestIds[key]) return
+ if (response.success && response.data) {
+ data.settings[key] = response.data[key]
+ } else {
data.settings[key] = previous
error.value = response.error ?? 'request_failed'
}
}
async function saveBadge(badge: string): Promise {
+ const requestId = ++badgeRequestId
error.value = ''
const response = await nuiCall<{ badge: string }>('radio:save-badge', {
badge,
})
+ if (requestId !== badgeRequestId) return true
if (response.success && response.data) data.badge = response.data.badge
else error.value = response.error ?? 'request_failed'
return response.success
}
async function saveDisplayName(displayName: string): Promise {
+ const requestId = ++displayNameRequestId
error.value = ''
const response = await nuiCall<{ displayName: string }>(
'radio:save-display-name',
{ displayName },
)
+ if (requestId !== displayNameRequestId) return true
if (response.success && response.data)
data.displayName = response.data.displayName
else error.value = response.error ?? 'request_failed'
diff --git a/frontend/src/views/apps/RadioApp.vue b/frontend/src/views/apps/RadioApp.vue
index ea762d9..8658fdb 100644
--- a/frontend/src/views/apps/RadioApp.vue
+++ b/frontend/src/views/apps/RadioApp.vue
@@ -1,20 +1,4 @@
-
-
-
-
-
-
- {{ phone.t('Apps.radio.tabs.radio') }}
-
-
-
- {{ phone.t('Apps.radio.tabs.settings') }}
-
-
-
-
+
+
-
-
- {{ phone.t('Common.loading') }}
+
+
+
+
+ {{ phone.t('Apps.radio.tabs.radio') }}
+
+
+
+ {{ phone.t('Apps.radio.tabs.settings') }}
+
+
-
-
-
-
- {{ statusText }}
- {{
- radio.data.provider ?? phone.t('Apps.radio.noProvider')
- }}
-
-
-
+
+
+
+ {{ phone.t('Common.loading') }}
+
- {{ phone.t('Apps.radio.channel') }}
-
-
+
- {{ phone.t('Apps.radio.mhz') }}
-
-
- {{ phone.t('Apps.radio.mhz') }}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.radio.mhz') }}
+
+
+
+
+
+
+
+ {{ phone.t('Apps.radio.mhz') }}
+
+
+
+
+
+
+
- {{ radio.data.volume }}%
-
-
-
-
+ @change="saveVolume"
+ >
+
+
+
+
-
-
- {{ phone.t('Apps.radio.connect') }}
-
-
- {{ phone.t('Apps.radio.disconnect') }}
-
-
- {{ errorText(radio.error) }}
-
-
+
+
+ {{ phone.t('Apps.radio.connect') }}
+
+
+ {{ phone.t('Apps.radio.disconnect') }}
+
+
+ {{ errorText(radio.error) }}
+
+
+
-
-
-
- {{
+
-
+
-
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- {{ phone.t('Apps.radio.history') }}
-
-
-
-
-
-
-
-
-
-
-
- {{ phone.t('Apps.radio.displayName') }}
-
-
-
+
-
-
-
- {{
- phone.t(
- radio.data.displayNameAllowed
- ? 'Apps.radio.displayNameDescription'
- : 'Apps.radio.displayNameNotAllowed',
- )
- }}
-
-
-
+
-
-
- {{ phone.t('Apps.radio.badge') }}
-
-
-
+
-
+
+
+
+
+
+
+
-
-
- {{ phone.t('Common.save') }}
-
-
-
-
- {{ phone.t('Apps.radio.otherSettings') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ feedback }}
-
-
+
+
From 32e49928ef0cc9a3080896d7562cdb11431fb7c8 Mon Sep 17 00:00:00 2001
From: DerEchteAlec
Date: Thu, 13 Aug 2026 03:10:26 +0200
Subject: [PATCH 52/63] ENH - migrate Companies to Sky UI
---
frontend/src/stores/companies.test.ts | 217 ++-
frontend/src/stores/companies.ts | 153 +-
frontend/src/views/apps/CompaniesApp.vue | 1917 ++++++++++------------
3 files changed, 1230 insertions(+), 1057 deletions(-)
diff --git a/frontend/src/stores/companies.test.ts b/frontend/src/stores/companies.test.ts
index 2a49e89..1ceb335 100644
--- a/frontend/src/stores/companies.test.ts
+++ b/frontend/src/stores/companies.test.ts
@@ -5,10 +5,12 @@ import { useCompaniesStore } from '@/stores/companies'
import type {
Company,
CompanyDirectoryFilters,
+ CompanyDirectoryPage,
CompanyRequest,
+ CompanyRequestPage,
CompanySummary,
} from '@/types/companies'
-import { nuiCall } from '@/utils/nui'
+import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
@@ -84,6 +86,17 @@ const filters: CompanyDirectoryFilters = {
sort: 'relevance',
}
+function deferred(): {
+ promise: Promise
+ resolve: (value: T) => void
+} {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
describe('companies store', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -132,6 +145,208 @@ describe('companies store', () => {
})
})
+ it('ignores an older directory response after the filters change', async () => {
+ const firstResponse = deferred>()
+ const secondResponse = deferred>()
+ mockNuiCall
+ .mockReturnValueOnce(firstResponse.promise)
+ .mockReturnValueOnce(secondResponse.promise)
+ const store = useCompaniesStore()
+ const searchedFilters = { ...filters, search: 'medical' }
+ const medical = {
+ ...summary,
+ id: 'medical',
+ name: 'Los Santos Medical',
+ }
+
+ const firstLoad = store.loadCompanies(filters)
+ const secondLoad = store.loadCompanies(searchedFilters)
+ secondResponse.resolve({
+ data: { categories: [], companies: [medical], nextCursor: null },
+ success: true,
+ })
+
+ await expect(secondLoad).resolves.toBe(true)
+ firstResponse.resolve({
+ data: { categories: [], companies: [summary], nextCursor: null },
+ success: true,
+ })
+
+ await expect(firstLoad).resolves.toBe(false)
+ expect(store.directory).toEqual([medical])
+ expect(store.directoryFilters).toEqual(searchedFilters)
+ expect(store.directoryLoading).toBe(false)
+ })
+
+ it('ignores an older customer request response after the list changes', async () => {
+ const firstResponse = deferred>()
+ const secondResponse = deferred>()
+ mockNuiCall
+ .mockReturnValueOnce(firstResponse.promise)
+ .mockReturnValueOnce(secondResponse.promise)
+ const store = useCompaniesStore()
+ const closedRequest = { ...request, id: 'request-closed' }
+
+ const firstLoad = store.loadMyRequests('open')
+ const secondLoad = store.loadMyRequests('closed')
+ secondResponse.resolve({
+ data: { nextCursor: null, requests: [closedRequest], unreadCount: 1 },
+ success: true,
+ })
+
+ await expect(secondLoad).resolves.toBe(true)
+ firstResponse.resolve({
+ data: { nextCursor: null, requests: [request], unreadCount: 7 },
+ success: true,
+ })
+
+ await expect(firstLoad).resolves.toBe(false)
+ expect(store.myRequests).toEqual([closedRequest])
+ expect(store.myRequestsList).toBe('closed')
+ expect(store.customerUnreadCount).toBe(1)
+ expect(store.myRequestsLoading).toBe(false)
+ })
+
+ it('keeps directory data and its initial error when an append fails', async () => {
+ mockNuiCall
+ .mockResolvedValueOnce({
+ error: 'temporarily_unavailable',
+ success: false,
+ })
+ .mockResolvedValueOnce({
+ data: {
+ categories: [],
+ companies: [
+ { ...summary, id: 'medical', name: 'Los Santos Medical' },
+ ],
+ nextCursor: null,
+ },
+ success: true,
+ })
+ const store = useCompaniesStore()
+ store.directory = [summary]
+ store.directoryError = 'initial_error'
+ store.directoryFilters = { ...filters }
+ store.directoryNextCursor = 'page-2'
+
+ expect(await store.loadCompanies(filters, true)).toBe(false)
+ expect(store.directory).toEqual([summary])
+ expect(store.directoryNextCursor).toBe('page-2')
+ expect(store.directoryError).toBe('initial_error')
+ expect(store.directoryAppendError).toBe('temporarily_unavailable')
+
+ expect(await store.loadCompanies(filters, true)).toBe(true)
+ expect(store.directory.map((item) => item.id)).toEqual([
+ 'police',
+ 'medical',
+ ])
+ expect(store.directoryError).toBe('initial_error')
+ expect(store.directoryAppendError).toBe('')
+ })
+
+ it('keeps customer requests and their initial error when an append fails', async () => {
+ mockNuiCall
+ .mockResolvedValueOnce({
+ error: 'temporarily_unavailable',
+ success: false,
+ })
+ .mockResolvedValueOnce({
+ data: {
+ nextCursor: null,
+ requests: [{ ...request, id: 'request-2' }],
+ unreadCount: 2,
+ },
+ success: true,
+ })
+ const store = useCompaniesStore()
+ store.customerUnreadCount = 1
+ store.myRequests = [request]
+ store.myRequestsError = 'initial_error'
+ store.myRequestsList = 'open'
+ store.myRequestsNextCursor = 'page-2'
+
+ expect(await store.loadMyRequests('open', true)).toBe(false)
+ expect(store.myRequests).toEqual([request])
+ expect(store.myRequestsNextCursor).toBe('page-2')
+ expect(store.customerUnreadCount).toBe(1)
+ expect(store.myRequestsError).toBe('initial_error')
+ expect(store.myRequestsAppendError).toBe('temporarily_unavailable')
+
+ expect(await store.loadMyRequests('open', true)).toBe(true)
+ expect(store.myRequests.map((item) => item.id)).toEqual([
+ 'request-1',
+ 'request-2',
+ ])
+ expect(store.customerUnreadCount).toBe(2)
+ expect(store.myRequestsError).toBe('initial_error')
+ expect(store.myRequestsAppendError).toBe('')
+ })
+
+ it('invalidates an in-flight customer page when the device scope is cleared', async () => {
+ const response = deferred>()
+ mockNuiCall.mockReturnValueOnce(response.promise)
+ const store = useCompaniesStore()
+ store.bindDeviceScope('device-a', 'sim-a')
+ store.myRequests = [request]
+ store.myRequestsList = 'open'
+ store.myRequestsNextCursor = 'page-2'
+
+ const load = store.loadMyRequests('open', true)
+ store.resetDeviceScope()
+ response.resolve({
+ data: {
+ nextCursor: null,
+ requests: [{ ...request, id: 'request-stale' }],
+ unreadCount: 8,
+ },
+ success: true,
+ })
+
+ await expect(load).resolves.toBe(false)
+ expect(store.myRequests).toEqual([])
+ expect(store.customerUnreadCount).toBe(0)
+ expect(store.myRequestsLoaded).toBe(false)
+ expect(store.myRequestsLoadingMore).toBe(false)
+ })
+
+ it('guards directory appends by loading state, cursor and exact filters', async () => {
+ const store = useCompaniesStore()
+ store.directoryFilters = { ...filters }
+ store.directoryNextCursor = 'page-2'
+ store.directoryLoading = true
+
+ expect(await store.loadCompanies(filters, true)).toBe(false)
+ store.directoryLoading = false
+ expect(
+ await store.loadCompanies({ ...filters, categoryId: 'public' }, true),
+ ).toBe(false)
+ store.directoryNextCursor = null
+ expect(await store.loadCompanies(filters, true)).toBe(false)
+ store.directoryNextCursor = 'page-2'
+ store.directoryLoadingMore = true
+ expect(await store.loadCompanies(filters, true)).toBe(false)
+
+ expect(mockNuiCall).not.toHaveBeenCalled()
+ })
+
+ it('guards customer appends by loading state, cursor and exact list', async () => {
+ const store = useCompaniesStore()
+ store.myRequestsList = 'open'
+ store.myRequestsNextCursor = 'page-2'
+ store.myRequestsLoading = true
+
+ expect(await store.loadMyRequests('open', true)).toBe(false)
+ store.myRequestsLoading = false
+ expect(await store.loadMyRequests('closed', true)).toBe(false)
+ store.myRequestsNextCursor = null
+ expect(await store.loadMyRequests('open', true)).toBe(false)
+ store.myRequestsNextCursor = 'page-2'
+ store.myRequestsLoadingMore = true
+ expect(await store.loadMyRequests('open', true)).toBe(false)
+
+ expect(mockNuiCall).not.toHaveBeenCalled()
+ })
+
it('uses server-provided unread counts for the app badge', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
diff --git a/frontend/src/stores/companies.ts b/frontend/src/stores/companies.ts
index 4e46162..e2297da 100644
--- a/frontend/src/stores/companies.ts
+++ b/frontend/src/stores/companies.ts
@@ -35,6 +35,20 @@ function mergeById(current: T[], incoming: T[]): T[] {
return [...merged.values()]
}
+function sameDirectoryFilters(
+ current: CompanyDirectoryFilters,
+ requested: CompanyDirectoryFilters,
+): boolean {
+ return (
+ current.acceptsRequests === requested.acceptsRequests &&
+ current.availability === requested.availability &&
+ current.categoryId === requested.categoryId &&
+ current.hasLocation === requested.hasLocation &&
+ current.search === requested.search &&
+ current.sort === requested.sort
+ )
+}
+
export const useCompaniesStore = defineStore('companies', {
state: () => ({
categories: [] as CompanyDirectoryPage['categories'],
@@ -49,11 +63,13 @@ export const useCompaniesStore = defineStore('companies', {
search: '',
sort: 'relevance',
} as CompanyDirectoryFilters,
+ directoryAppendError: '',
directoryError: '',
directoryLoaded: false,
directoryLoading: false,
directoryLoadingMore: false,
directoryNextCursor: null as string | null,
+ directoryRequestGeneration: 0,
deviceScopeKey: '',
deviceScopeVersion: 0,
members: [] as CompanyMember[],
@@ -61,12 +77,14 @@ export const useCompaniesStore = defineStore('companies', {
mutationError: '',
mutating: false,
myRequests: [] as CompanyRequestSummary[],
+ myRequestsAppendError: '',
myRequestsList: 'open' as CompanyRequestList,
myRequestsError: '',
myRequestsLoaded: false,
myRequestsLoading: false,
myRequestsLoadingMore: false,
myRequestsNextCursor: null as string | null,
+ myRequestsRequestGeneration: 0,
request: null as CompanyRequest | null,
requestError: '',
requestLoading: false,
@@ -151,38 +169,67 @@ export const useCompaniesStore = defineStore('companies', {
filters: CompanyDirectoryFilters,
append = false,
): Promise {
- if (append && (!this.directoryNextCursor || this.directoryLoadingMore)) {
+ const requestFilters = { ...filters }
+ if (
+ append &&
+ (this.directoryLoading ||
+ this.directoryLoadingMore ||
+ !this.directoryNextCursor ||
+ !sameDirectoryFilters(this.directoryFilters, requestFilters))
+ ) {
return false
}
- if (append) this.directoryLoadingMore = true
- else this.directoryLoading = true
+ if (append) {
+ this.directoryLoadingMore = true
+ } else {
+ this.directoryRequestGeneration += 1
+ this.directoryLoading = true
+ this.directoryLoadingMore = false
+ this.directoryAppendError = ''
+ this.directoryError = ''
+ this.directoryFilters = requestFilters
+ }
this.directoryLoaded = true
- this.directoryFilters = { ...filters }
+ const requestGeneration = this.directoryRequestGeneration
+ const requestCursor = append ? this.directoryNextCursor : null
const response = await nuiCall('companies:list', {
- acceptsRequests: filters.acceptsRequests,
- availability: filters.availability,
- categoryId: filters.categoryId,
- cursor: append ? this.directoryNextCursor : null,
- hasLocation: filters.hasLocation,
- search: filters.search,
- sort: filters.sort,
+ acceptsRequests: requestFilters.acceptsRequests,
+ availability: requestFilters.availability,
+ categoryId: requestFilters.categoryId,
+ cursor: requestCursor,
+ hasLocation: requestFilters.hasLocation,
+ search: requestFilters.search,
+ sort: requestFilters.sort,
})
- this.directoryLoading = false
- this.directoryLoadingMore = false
- if (!response.success || !response.data) {
- this.directoryError = response.error ?? 'request_failed'
- if (!append) {
- this.directory = []
- this.directoryNextCursor = null
+ const isCurrentRequest =
+ requestGeneration === this.directoryRequestGeneration &&
+ sameDirectoryFilters(this.directoryFilters, requestFilters)
+ if (!isCurrentRequest) {
+ if (requestGeneration === this.directoryRequestGeneration) {
+ if (append) this.directoryLoadingMore = false
+ else this.directoryLoading = false
}
return false
}
+ if (append) this.directoryLoadingMore = false
+ else this.directoryLoading = false
+ if (!response.success || !response.data) {
+ if (append) {
+ this.directoryAppendError = response.error ?? 'request_failed'
+ return false
+ }
+ this.directoryError = response.error ?? 'request_failed'
+ this.directory = []
+ this.directoryNextCursor = null
+ return false
+ }
this.categories = response.data.categories ?? this.categories
this.directory = append
? mergeById(this.directory, response.data.companies)
: response.data.companies
this.directoryNextCursor = response.data.nextCursor
- this.directoryError = ''
+ if (append) this.directoryAppendError = ''
+ else this.directoryError = ''
return true
},
async loadCompany(companyId: string): Promise {
@@ -206,37 +253,64 @@ export const useCompaniesStore = defineStore('companies', {
): Promise {
if (
append &&
- (!this.myRequestsNextCursor || this.myRequestsLoadingMore)
+ (this.myRequestsLoading ||
+ this.myRequestsLoadingMore ||
+ !this.myRequestsNextCursor ||
+ list !== this.myRequestsList)
) {
return false
}
- if (append) this.myRequestsLoadingMore = true
- else this.myRequestsLoading = true
+ if (append) {
+ this.myRequestsLoadingMore = true
+ } else {
+ this.myRequestsRequestGeneration += 1
+ this.myRequestsLoading = true
+ this.myRequestsLoadingMore = false
+ this.myRequestsAppendError = ''
+ this.myRequestsError = ''
+ this.myRequestsList = list
+ }
this.myRequestsLoaded = true
- this.myRequestsList = list
const deviceScopeVersion = this.deviceScopeVersion
+ const requestGeneration = this.myRequestsRequestGeneration
+ const requestCursor = append ? this.myRequestsNextCursor : null
const response = await nuiCall(
'companies:my-requests',
{
- cursor: append ? this.myRequestsNextCursor : null,
+ cursor: requestCursor,
list,
},
)
- this.myRequestsLoading = false
- this.myRequestsLoadingMore = false
- if (deviceScopeVersion !== this.deviceScopeVersion) return false
+ const isCurrentRequest =
+ deviceScopeVersion === this.deviceScopeVersion &&
+ requestGeneration === this.myRequestsRequestGeneration &&
+ list === this.myRequestsList
+ if (!isCurrentRequest) {
+ if (
+ deviceScopeVersion === this.deviceScopeVersion &&
+ requestGeneration === this.myRequestsRequestGeneration
+ ) {
+ if (append) this.myRequestsLoadingMore = false
+ else this.myRequestsLoading = false
+ }
+ return false
+ }
+ if (append) this.myRequestsLoadingMore = false
+ else this.myRequestsLoading = false
if (!response.success || !response.data) {
+ if (append) {
+ this.myRequestsAppendError = response.error ?? 'request_failed'
+ return false
+ }
this.myRequestsError = response.error ?? 'request_failed'
- if (!append) {
- this.myRequests = []
- this.myRequestsNextCursor = null
- if (
- response.error === 'anonymous_sim' ||
- response.error === 'device_not_found' ||
- response.error === 'no_sim'
- ) {
- this.customerUnreadCount = 0
- }
+ this.myRequests = []
+ this.myRequestsNextCursor = null
+ if (
+ response.error === 'anonymous_sim' ||
+ response.error === 'device_not_found' ||
+ response.error === 'no_sim'
+ ) {
+ this.customerUnreadCount = 0
}
return false
}
@@ -245,7 +319,8 @@ export const useCompaniesStore = defineStore('companies', {
: response.data.requests
this.myRequestsNextCursor = response.data.nextCursor
this.customerUnreadCount = Math.max(0, response.data.unreadCount)
- this.myRequestsError = ''
+ if (append) this.myRequestsAppendError = ''
+ else this.myRequestsError = ''
return true
},
async loadRequest(requestId: string): Promise {
@@ -524,8 +599,10 @@ export const useCompaniesStore = defineStore('companies', {
},
resetDeviceScope(): void {
this.deviceScopeVersion += 1
+ this.myRequestsRequestGeneration += 1
this.customerUnreadCount = 0
this.myRequests = []
+ this.myRequestsAppendError = ''
this.myRequestsError = ''
this.myRequestsLoaded = false
this.myRequestsLoading = false
diff --git a/frontend/src/views/apps/CompaniesApp.vue b/frontend/src/views/apps/CompaniesApp.vue
index efe4e4e..ab8f39d 100644
--- a/frontend/src/views/apps/CompaniesApp.vue
+++ b/frontend/src/views/apps/CompaniesApp.vue
@@ -1,42 +1,47 @@
-
-
-
-
-
-
-
+
-
-
+
-
+
-
-
-
-
{{ phone.t('Apps.companies.directory.allCategories') }}
-
-
+
{{ categoryLabel(category.id, category.name) }}
-
-
+
+
-
-
- {{ phone.t('Apps.companies.loading.directory') }}
-
-
+
+
-
- {{ phone.t('Apps.companies.states.directoryError') }}
- {{ errorText(companies.directoryError) }}
-
-
- {{ phone.t('Apps.companies.tryAgain') }}
-
-
-
-
-
- {{
- phone.t(
- filtersActive
- ? 'Apps.companies.states.noResults'
- : 'Apps.companies.states.noCompanies',
- )
- }}
-
-
- {{
- phone.t(
- filtersActive
- ? 'Apps.companies.states.noResultsBody'
- : 'Apps.companies.states.noCompaniesBody',
- )
- }}
-
-
- {{ phone.t('Apps.companies.directory.resetFilters') }}
-
-
-
-
- {{
- phone.t('Apps.companies.directory.availableSection')
- }}
-
-
-
-
- {{ companyInitials(company) }}
-
- {{ company.name }}
- {{
- company.location?.district ??
- categoryLabel(company.categoryId, company.categoryName)
- }}
-
- {{
- phone.t(`Apps.companies.availability.${company.availability}`)
- }}
-
-
-
+
+
+
+
+ {{ phone.t('Apps.companies.tryAgain') }}
+
-
- {{
- phone.t('Apps.companies.directory.allCompanies')
- }}
-
-
+
+