From 8cdd5d3e15a7417af63c21674e0f5c477415a747 Mon Sep 17 00:00:00 2001 From: Alec Schitzkat Date: Wed, 12 Aug 2026 18:52:14 +0200 Subject: [PATCH] Auto stash before merge of "dev" and "origin/dev" --- README.md | 39 ++- frontend/src/App.vue | 113 ++++++-- frontend/src/assets/main.css | 68 +++-- frontend/src/components/AppIcon.vue | 62 +++-- frontend/src/components/CustomAppFrame.vue | 2 + frontend/src/components/DarkChatSelect.vue | 5 +- frontend/src/components/EasyShareSheet.vue | 30 ++- .../components/MessageAttachmentBubble.vue | 41 ++- .../src/components/MessageContactBubble.vue | 1 + frontend/src/components/PayphoneOverlay.vue | 6 +- frontend/src/components/PhoneMediaCapture.vue | 236 ++++++++++++---- frontend/src/components/SharedContentCard.vue | 1 + frontend/src/components/SimPhonePicker.vue | 1 - frontend/src/components/SpringboardWidget.vue | 62 +++-- .../src/components/SpringboardWidgetGrid.vue | 3 + .../components/citymarkt/CityMarktSelect.vue | 3 + .../components/feather/FeatherPostCard.vue | 11 + 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/gameView.test.ts | 93 ++++++- frontend/src/utils/gameView.ts | 255 ++++++++++++------ frontend/src/utils/homeLayout.test.ts | 10 + frontend/src/utils/homeLayout.ts | 30 +++ frontend/src/utils/keyboard.test.ts | 98 +++++++ frontend/src/utils/keyboard.ts | 47 ++++ frontend/src/utils/mediaRecorder.test.ts | 66 +++++ frontend/src/utils/mediaRecorder.ts | 62 +++++ frontend/src/utils/musicEscape.test.ts | 38 +++ frontend/src/utils/musicEscape.ts | 22 ++ frontend/src/utils/nui.test.ts | 63 +++++ frontend/src/utils/nui.ts | 12 +- frontend/src/utils/preferences.test.ts | 11 + frontend/src/utils/preferences.ts | 18 +- frontend/src/utils/widgetLayout.test.ts | 16 ++ frontend/src/utils/widgetLayout.ts | 27 ++ frontend/src/views/SpringboardView.vue | 30 +++ frontend/src/views/apps/BankingApp.vue | 113 ++++++-- frontend/src/views/apps/BillingApp.vue | 25 ++ frontend/src/views/apps/CameraApp.vue | 21 +- frontend/src/views/apps/CompaniesApp.vue | 3 +- frontend/src/views/apps/CrewLinkApp.vue | 37 ++- frontend/src/views/apps/DarkChatApp.vue | 5 +- frontend/src/views/apps/FeatherApp.vue | 101 ++++++- frontend/src/views/apps/FlareApp.vue | 20 +- frontend/src/views/apps/FlipTokApp.vue | 111 +++++++- frontend/src/views/apps/GalleryApp.vue | 71 ++++- frontend/src/views/apps/MapApp.vue | 3 +- frontend/src/views/apps/MessagesApp.vue | 3 +- frontend/src/views/apps/MusicApp.vue | 74 +++-- frontend/src/views/apps/NotesApp.vue | 3 +- frontend/src/views/apps/NumberMergeApp.vue | 55 ++-- frontend/src/views/apps/PhoneApp.vue | 2 +- frontend/src/views/apps/PicstagramApp.vue | 6 + frontend/testserver/index.cjs | 3 +- sky_phone/config/media.lua | 4 +- 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/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 +- 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 +++ 82 files changed, 3460 insertions(+), 519 deletions(-) create mode 100644 frontend/src/stores/phone-persistence.test.ts create mode 100644 frontend/src/utils/keyboard.test.ts create mode 100644 frontend/src/utils/keyboard.ts create mode 100644 frontend/src/utils/mediaRecorder.test.ts create mode 100644 frontend/src/utils/mediaRecorder.ts create mode 100644 frontend/src/utils/musicEscape.test.ts create mode 100644 frontend/src/utils/musicEscape.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/README.md b/README.md index c8019bf..4b8324a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,22 @@ # sky_phone +## Payphones + +Payphone dialing is validated against server-owned booth positions. Vanilla GTA V positions live +in `sky_phone/config/payphones.lua`; the server compares its own player coordinates with this list +and does not trust coordinates or models sent by the NUI or client. + +For a custom map or MLO, add each booth to `Config.Payphones.Locations` in that server-only file: + +```lua +{ model = "prop_phonebox_01a", coords = { x = 123.45, y = 678.90, z = 21.0 } }, +``` + +The model must also be present in `Config.Payphones.Props`. Coordinates must be finite Lua numbers +inside the supported world bounds. Restart `sky_phone` after changing the list. If payphones are +enabled but the list is empty or contains invalid entries, the server logs a visible warning and +rejects calls that cannot be matched to a valid configured location. + ## Custom Apps `sky_phone` erkennt Registrierungen gestarteter Fremd-App-Ressourcen über integrierte @@ -167,23 +184,25 @@ and restart the resource after updating the configuration. - When `Config.Sim.Enabled = true`, two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. These item definitions are not required when SIM cards are disabled. - `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. +- A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set it as a + server-only convar; the token is never sent to NUI because clients receive temporary presigned + upload URLs instead: + +```cfg +set sky_phone_fivemanage_api_key "replace-with-your-media-token" +``` - `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 -Configure GIF search in `sky_phone/config/config.lua`: +Configure GIF search as a server-only convar: -```lua -Config.Media.GiphyApiKey = "YOUR_GIPHY_API_KEY" +```cfg +set sky_phone_giphy_api_key "replace-with-your-giphy-api-key" ``` -GIPHY provides trending and searched GIFs through a paginated server-side proxy. The shared -`config.lua` is loaded by both FiveM runtimes, so its values are available to clients even though -only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive -until their dedicated implementation is available. +GIPHY provides trending and searched GIFs through a paginated server-side proxy. Only the server +reads the key. Photo and video actions in Messages use media captured by the Camera app. Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. The migration also creates `sky_phone_character_devices` for persistent non-unique phone mappings and marks automatic SIMs through `sky_phone_sims.is_virtual`. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password. 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/assets/main.css b/frontend/src/assets/main.css index 10e108c..2769c81 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -14,9 +14,13 @@ .sim-picker { position: relative; width: min(58vh, 90vw); - max-height: 46vh; - overflow: clip; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + min-height: 0; + overflow: hidden; padding: 2.2vh; + display: flex; + flex-direction: column; border: 0.1vh solid rgb(255 255 255 / 12%); border-radius: 0.9vh; background: #050505; @@ -56,11 +60,16 @@ .sim-picker__header { display: flex; + flex: 0 0 auto; justify-content: space-between; gap: 2vh; margin-bottom: 1.6vh; } +.sim-picker__header > div { + min-width: 0; +} + .sim-picker__header h1, .sim-picker__confirmation h2 { margin: 0; @@ -73,6 +82,7 @@ margin: 0.45vh 0 0; color: #9ca3af; font-size: 1.15vh; + overflow-wrap: anywhere; } .sim-picker__close { @@ -80,6 +90,8 @@ place-items: center; width: 2.8vh; height: 2.8vh; + min-width: 32px; + min-height: 32px; border: 0; border-radius: 0.35vh; background: #1dd1ce; @@ -96,10 +108,12 @@ position: relative; z-index: 1; display: grid; + flex: 1 1 auto; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1vh; - max-height: 34vh; - overflow: auto; + min-height: 0; + max-height: 54vh; + overflow-y: auto; } .sim-picker__card { @@ -140,30 +154,34 @@ } .sim-picker__details strong { - overflow: hidden; font-size: 1.2vh; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .sim-picker__details small { color: #9ca3af; font-size: 0.95vh; + overflow-wrap: anywhere; } .sim-picker__confirmation { + min-height: 0; + overflow-y: auto; padding: 2vh 0 0; text-align: center; } .sim-picker__confirmation > div { display: flex; + flex-wrap: wrap; justify-content: center; gap: 1vh; margin-top: 2vh; } .sim-picker__confirmation button { + min-height: 40px; + overflow-wrap: anywhere; padding: 0.8vh 1.6vh; border: 0.1vh solid rgb(255 255 255 / 18%); border-radius: 0.45vh; @@ -184,6 +202,13 @@ color: #ff453a; font-size: 1vh; text-align: center; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .sim-picker__cards { + grid-template-columns: 1fr; + } } :root { font-family: @@ -205,6 +230,7 @@ body, margin: 0; overflow: hidden; background: transparent !important; + pointer-events: none; user-select: none; } button, @@ -885,13 +911,13 @@ button { } /* DarkChat is intentionally independent from the phone appearance setting. */ -.phone-app:has(.darkchat-page), -.phone-app:has(.darkchat-page) .phone-app-window, -.phone-app:has(.darkchat-page) .phone-app-window__content { +.phone-app--darkchat, +.phone-app--darkchat .phone-app-window, +.phone-app--darkchat .phone-app-window__content { background: #000 !important; color-scheme: dark; } -.phone-app:has(.darkchat-page) .phone-status-bar { +.phone-app--darkchat .phone-status-bar { color: #fff !important; --status-bar-color: #fff; } @@ -1592,7 +1618,7 @@ button { font-size: 9px; white-space: nowrap; } -.darkchat-message:has(.darkchat-reactions) { +.darkchat-message { margin-bottom: 12px; } .darkchat-replying { @@ -3714,7 +3740,7 @@ button { .clock-world-time { margin-top: 8px; font-variant-numeric: tabular-nums; - font-size: min(76px, 20cqw); + font-size: 74px; font-weight: 200; letter-spacing: -4px; } @@ -3861,7 +3887,7 @@ button { .clock-digits { margin: 70px 0 54px; font-variant-numeric: tabular-nums; - font-size: min(52px, 14cqw); + font-size: 52px; font-weight: 200; } .clock-sound-menu { @@ -4242,11 +4268,11 @@ button { background: #fff !important; color: #111; } -.phone-app:has(.messages-page) .phone-status-bar { +.phone-app--messages .phone-status-bar { color: #080808; text-shadow: none; } -.phone-app:has(.messages-page)::before { +.phone-app--messages::before { content: ''; position: absolute; z-index: 60; @@ -5458,7 +5484,7 @@ button { } } /* iOS 26-style Liquid Glass refinements and complete media pickers. */ -.phone-app:has(.messages-page) { +.messages-page { --ios-glass: rgb(252 252 255 / 70%); --ios-glass-strong: rgb(255 255 255 / 86%); --ios-glass-border: rgb(255 255 255 / 72%); @@ -5779,21 +5805,21 @@ button { .messages-attachment--video small { z-index: 2; } -.messages-attachment--gif:has(img) { +.messages-attachment--gif { background: #e9e9ed; } /* Konsta's k-app dark state drives the complete iOS Messages palette. */ -.phone-app.dark:has(.messages-page) { +.phone-app.dark .messages-page { --ios-glass: rgb(28 28 30 / 76%); --ios-glass-strong: rgb(36 36 38 / 90%); --ios-glass-border: rgb(255 255 255 / 12%); color-scheme: dark; } -.phone-app.dark:has(.messages-page)::before { +.phone-app--messages.dark::before { background: #000; } -.phone-app.dark:has(.messages-page) .phone-status-bar { +.phone-app--messages.dark .phone-status-bar { color: #fff; text-shadow: none; } diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 17fb331..8e2da12 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -12,6 +12,10 @@ import { useMarketplaceStore } from '@/stores/marketplace' import { useDarkChatStore } from '@/stores/darkchat' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppDefinition } from '@/types/apps' +import { + reorderDirectionFromKeyboard, + type ReorderDirection, +} from '@/utils/keyboard' const props = withDefaults( defineProps<{ @@ -33,6 +37,7 @@ const emit = defineEmits<{ dragstart: [event: PointerEvent] edit: [] remove: [] + reorder: [direction: ReorderDirection] }>() const phone = usePhoneStore() @@ -65,6 +70,8 @@ const suppressClick = ref(false) let holdTimer: number | undefined let calendarTimer: number | undefined let pointerStart = { x: 0, y: 0 } +let pointerTarget: HTMLElement | null = null +let pointerId: number | null = null watch( () => props.app.iconImage, @@ -135,6 +142,9 @@ function clearHold(): void { function onPointerDown(event: PointerEvent): void { if (props.compact || event.button !== 0) return + pointerTarget = event.currentTarget as HTMLElement + pointerId = event.pointerId + pointerTarget.setPointerCapture(pointerId) pointerStart = { x: event.clientX, y: event.clientY } clearHold() if (props.editMode) { @@ -173,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void { .closest('.springboard-page') ?.getBoundingClientRect().width ?? 0 isDragging.value = true - window.addEventListener('pointermove', onPointerMove) - window.addEventListener('pointerup', onPointerUp) - window.addEventListener('pointercancel', cancelPointerDrag) emit('dragstart', event) } function onPointerUp(event: PointerEvent): void { clearHold() - if (!isDragging.value) return - suppressClick.value = true - emit('dragend', event) - isDragging.value = false - dragOffset.value = { x: 0, y: 0 } - removeDragListeners() + if (isDragging.value) { + suppressClick.value = true + emit('dragend', event) + isDragging.value = false + dragOffset.value = { x: 0, y: 0 } + } + releasePointerCapture() } function cancelPointerDrag(): void { clearHold() - if (!isDragging.value) return + const wasDragging = isDragging.value isDragging.value = false dragOffset.value = { x: 0, y: 0 } - removeDragListeners() - emit('dragcancel') + releasePointerCapture() + if (wasDragging) emit('dragcancel') } -function removeDragListeners(): void { - window.removeEventListener('pointermove', onPointerMove) - window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('pointercancel', cancelPointerDrag) +function releasePointerCapture(): void { + if ( + pointerTarget && + pointerId !== null && + pointerTarget.hasPointerCapture(pointerId) + ) { + pointerTarget.releasePointerCapture(pointerId) + } + pointerTarget = null + pointerId = null +} + +function onKeydown(event: KeyboardEvent): void { + if (!props.editMode) return + const direction = reorderDirectionFromKeyboard(event) + if (!direction) return + event.preventDefault() + event.stopPropagation() + emit('reorder', direction) } onMounted(() => { @@ -214,7 +237,7 @@ onMounted(() => { onBeforeUnmount(() => { clearHold() if (calendarTimer !== undefined) window.clearInterval(calendarTimer) - removeDragListeners() + releasePointerCapture() }) @@ -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)) 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') }}
-
+
{ > @@ -460,13 +494,18 @@ onBeforeUnmount(() => {