From 65f8312a26bd86179986710093806347ad2bead9 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:51:03 +0200 Subject: [PATCH 1/7] ADD - seed browser and ingame phone test data --- README.md | 33 +- frontend/package.json | 3 +- frontend/src/App.vue | 47 ++ frontend/src/stores/phone.ts | 24 +- frontend/testserver/index.cjs | 271 +++++++- frontend/testserver/smoke.cjs | 354 ++++++++++ sky_phone/config/config.lua | 7 + sky_phone/config/locales/en.lua | 5 + sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 15 + sky_phone/source/server/testdata.lua | 964 +++++++++++++++++++++++++++ 11 files changed, 1691 insertions(+), 33 deletions(-) create mode 100644 frontend/testserver/smoke.cjs create mode 100644 sky_phone/source/server/testdata.lua diff --git a/README.md b/README.md index 1648ee4..c8019bf 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,20 @@ included in the NUI bundle. Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. Phone identity and SIM-card behavior are selected independently through `Config.Phone.Unique` and `Config.Sim.Enabled`. +### Ingame test data + +On development servers, enable `Config.TestData.Enabled` and run `/phonetestdata` as the player who +owns the phone. The command creates or refreshes idempotent, player-scoped fixtures for contacts, +calls, messages, mail, notes, gallery, banking history, billing, calendar, map markers, music, radio, +EasyShare, CityMarkt, Local Pages, Picstagram, FlipTok, Feather, Flare, DarkChat, CrewLink, SkyRide, +and company requests. It also creates a linked iFruit account and a registered SIM when the selected +phone does not have them yet. Reopen the phone after the command completes. + +Garage and Housing intentionally continue to use the real configured provider data. Apps without +persistent content, such as Calculator, Camera, Clock, Weather, Settings, and Payphone, do not need +database fixtures. Set `Config.TestData.AdminOnly = true` to restrict the command to the configured +framework admin groups, and disable the feature outside development environments. + An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. ## Phone identity and SIM modes @@ -228,7 +242,24 @@ The homescreen is an original implementation inspired by the interaction and lay ## Development -From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked. Feather can be opened directly with the following browser scenarios: +From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked with stateful data. Every built-in app can be opened directly by appending its id to `http://localhost:5174/?apiPort=3002#/apps/`: + +| Area | App ids | +| ------------------- | ------------------------------------------------------------------------------------------------------------- | +| Communication | `phone`, `messages`, `mail`, `darkchat`, `radio` | +| Social | `feather`, `fliptok`, `picstagram`, `flare`, `crewlink` | +| Services | `companies`, `citymarkt`, `local-pages`, `banking`, `billing`, `garage`, `house`, `map`, `skyride`, `weather` | +| Media and utilities | `camera`, `photos`, `music`, `calendar`, `notes`, `calculator`, `clock`, `app-store`, `settings` | +| Games | `snake`, `memory`, `number-merge`, `minesweeper`, `tower-stack`, `sky-flappy`, `neon-drop` | + +The browser bootstrap includes contacts, calls, messages, mail, invoices, transactions, vehicles, properties, companies, marketplace profiles and listings, social feeds, media, calendar entries, notes, alarms, game high scores, app settings, and persisted notifications. Mutating callbacks update the in-memory mock state until the mock server restarts. Unknown callbacks fail with `mock_endpoint_missing` instead of silently succeeding. + +System overlays are available through dedicated preview parameters: + +- SIM picker: `http://localhost:5174/?apiPort=3002&simPickerPreview=1` +- Payphone: `http://localhost:5174/?apiPort=3002&payphonePreview=1` (dial `5551110001` for a connected call or `5550000000` for a busy line) + +Feather can be opened directly with the following browser scenarios: - Full data: `http://localhost:5174/?apiPort=3002#/apps/feather` - Login and registration: `http://localhost:5174/?apiPort=3002&testScenario=feather-login#/apps/feather` diff --git a/frontend/package.json b/frontend/package.json index 69b2228..12fac4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "typecheck": "vue-tsc --build", "lint": "eslint .", "format": "prettier --write src/ testserver/ build.cjs", - "test": "vitest run" + "test": "vitest run && pnpm test:browser-mocks", + "test:browser-mocks": "node testserver/smoke.cjs" }, "dependencies": { "emoji-picker-element-data": "^1.8.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c3c8283..acf5d8b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -425,6 +425,50 @@ async function hydrateDevelopmentPhone(): Promise { }) } +function openDevelopmentPayphonePreview(): void { + window.dispatchEvent( + new MessageEvent('message', { + data: { + data: { + currency: '$', + locales: { + busy: 'LINE BUSY', + call: 'CALL', + callEnded: 'CALL ENDED', + clear: 'Clear number', + close: 'Close payphone', + connected: 'CONNECTED', + cost: 'COST', + declined: 'CALL DECLINED', + delete: 'Delete digit', + dialing: 'DIALING', + disconnected: 'DISCONNECTED', + elapsed: 'TIME', + hangup: 'HANG UP', + insufficientFunds: 'OUT OF MONEY', + invalidNumber: 'ENTER A VALID NUMBER', + keypad: 'Dial pad', + noAnswer: 'NO ANSWER', + numberLabel: 'NUMBER TO CALL', + numberPlaceholder: 'Enter a phone number', + rate: '{currency}{price} / SEC', + ready: 'READY', + requestFailed: 'CALL COULD NOT BE STARTED', + ringing: 'RINGING', + subtitle: 'PUBLIC TELEPHONE', + title: 'PAYPHONE', + unavailable: 'NUMBER UNAVAILABLE', + voiceUnavailable: 'VOICE SERVICE UNAVAILABLE', + }, + maxNumberLength: 10, + pricePerSecond: 2, + }, + type: 'payphone:open', + }, + }), + ) +} + function onMessage(event: MessageEvent): void { if (!isTrustedRootMessageSource(event.source, window)) return @@ -1056,6 +1100,9 @@ onMounted(() => { number: '5551234567', } } + if (developmentParameters.has('payphonePreview')) { + openDevelopmentPayphonePreview() + } } }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 18493bc..8ad26ae 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2543,7 +2543,19 @@ const defaultLocales: LocaleTree = { 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 +2695,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: + 'Sign in or create an iFruit account to build your local profile.', 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 +2720,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', diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 14c3e5b..88b5d49 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -7,6 +7,22 @@ const port = Number(process.argv[2]) || 3001 app.use(cors()) app.use(express.json()) +const lifecycleEndpoints = new Set([ + 'camera:setActive', + 'camera:setFacing', + 'camera:setFlash', + 'camera:setFocus', + 'camera:setOrientation', + 'camera:setZoom', + 'close', + 'custom-app:lifecycle', + 'device:notification-open', + 'notification:focus', + 'sim:picker-close', + 'ui:opened', + 'ui:ready', +]) + function calendarTime(dayOffset, hour, minute = 0) { const value = new Date() value.setDate(value.getDate() + dayOffset) @@ -1878,6 +1894,17 @@ const marketplaceListings = [ ], }, ] +let 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: 'demo@ifruit.com', + exists: true, + listing_count: marketplaceListings.filter( + (listing) => listing.seller_account_id === 1, + ).length, +} let linkedAccount = { devices: accountDevices, email: 'demo@ifruit.com', @@ -2063,6 +2090,38 @@ const deviceData = { }, revision: 2, }, + notifications: { + payload: { + items: [ + { + appId: 'messages', + id: 'demo-notification-message', + route: '/apps/messages?phoneNumber=5551110001', + subtitle: 'Alex Rivera', + text: 'Meet us at the observatory after sunset.', + title: 'Messages', + }, + { + appId: 'companies', + id: 'demo-notification-company', + route: '/apps/companies?area=requests', + subtitle: 'Los Santos Customs', + text: 'Your repair request has been accepted.', + title: 'Companies', + }, + { + appId: 'billing', + id: 'demo-notification-billing', + route: '/apps/billing', + subtitle: 'Los Santos Customs', + text: 'A new invoice for $1,850 is ready.', + title: 'Billing', + }, + ], + version: 1, + }, + revision: 1, + }, settings: { payload: { settings: { @@ -2085,6 +2144,14 @@ const deviceData = { } let mockPasscode = '' let mockSecurity = { enabled: false, length: null, lockedUntil: 0 } +let mockSim = { + id: 'development-sim', + number: '5551234567', + removable: true, + registered: true, + type: 'registered', +} +let mockPayphoneCall = null const blockedCallNumbers = new Set() let recentCalls = [ { @@ -3844,7 +3911,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', @@ -3868,7 +3936,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', @@ -3923,7 +3992,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', @@ -3998,6 +4068,10 @@ 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)) { + response.json({ success: true }) + return + } if (endpoint.startsWith('companies:') && testScenario === 'companies-error') { response.json({ success: false, error: 'service_unavailable' }) return @@ -5351,8 +5425,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) @@ -5832,6 +5905,47 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: flipTokActivities }) return } + if (endpoint === 'fliptok:mark-activities') { + const readAt = new Date().toISOString() + flipTokActivities = flipTokActivities.map((activity) => ({ + ...activity, + read_at: activity.read_at ?? readAt, + })) + response.json({ success: true }) + return + } + if (endpoint === 'fliptok:view') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + if (!video) { + response.json({ success: false, error: 'video_not_found' }) + return + } + video.view_count += 1 + response.json({ success: true }) + return + } + if (endpoint === 'fliptok:report') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + if (!video) { + response.json({ success: false, error: 'video_not_found' }) + return + } + flipTokReports.push({ + caption: video.caption, + created_at: Date.now(), + creator_display_name: video.display_name, + creator_handle: video.handle, + details: String(request.body.details ?? ''), + id: `report-${Date.now()}`, + reason: request.body.reason, + reporter_display_name: flipTokProfile.display_name, + reporter_handle: flipTokProfile.handle, + url: video.url, + video_id: video.id, + }) + response.json({ success: true }) + return + } if (endpoint === 'fliptok:profile') { const profileId = Number(request.body.profileId || 0) const handle = String(request.body.handle || '').toLowerCase() @@ -6722,8 +6836,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, } @@ -6968,13 +7081,7 @@ app.post('/api/:endpoint', (request, response) => { data: deviceData, imei: '356938035643809', name: 'Personal iFruit Phone', - sim: { - id: 'development-sim', - number: '5551234567', - removable: true, - registered: true, - type: 'registered', - }, + sim: mockSim, }, notes: mockNotes, security: mockSecurity, @@ -7091,11 +7198,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, @@ -7304,6 +7411,15 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { revision } }) return } + if (endpoint === 'notifications:save') { + const revision = (deviceData.notifications?.revision ?? 0) + 1 + deviceData.notifications = { + payload: request.body.payload, + revision, + } + response.json({ success: true, data: { revision } }) + return + } if (endpoint === 'security:unlock') { response.json( !mockSecurity.enabled || request.body.passcode === mockPasscode @@ -7531,9 +7647,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 @@ -7544,7 +7663,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 @@ -7709,6 +7830,42 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: false, error: 'not_authenticated' }) return } + if (endpoint === 'marketplace:profile') { + marketplaceProfile.listing_count = marketplaceListings.filter( + (listing) => listing.seller_account_id === 1, + ).length + response.json({ success: true, data: marketplaceProfile }) + return + } + if (endpoint === 'marketplace:profile-save') { + const displayName = String(request.body.displayName ?? '').trim() + const bio = String(request.body.bio ?? '').trim() + const avatarMediaId = Number(request.body.avatarMediaId) + const avatar = mockMedia.find( + (item) => item.id === avatarMediaId && item.mediaType === 'photo', + ) + if ( + displayName.length < 2 || + displayName.length > 40 || + bio.length > 160 || + !Number.isInteger(avatarMediaId) || + avatarMediaId < 0 || + (avatarMediaId > 0 && !avatar) + ) { + response.json({ success: false, error: 'invalid_profile' }) + return + } + marketplaceProfile = { + ...marketplaceProfile, + avatar_media_id: avatarMediaId || null, + avatar_url: avatar?.url ?? null, + bio, + display_name: displayName, + exists: true, + } + response.json({ success: true, data: marketplaceProfile }) + return + } if (endpoint === 'marketplace:counts') { response.json({ success: true, @@ -8010,6 +8167,61 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: false, error: 'confirmation_required' }) return } + if (endpoint === 'sim:insert') { + mockSim = { + id: `development-sim-${request.body.imei}`, + number: + request.body.imei === '356938035643810' ? '5559876543' : '5551234567', + removable: true, + registered: true, + type: 'registered', + } + response.json({ success: true }) + return + } + if (endpoint === 'sim:eject') { + if (!mockSim) { + response.json({ success: false, error: 'no_sim' }) + return + } + mockSim = null + response.json({ success: true }) + return + } + if (endpoint === 'payphone:dial') { + const phoneNumber = String(request.body.phoneNumber ?? '').replace( + /\D/g, + '', + ) + if (phoneNumber.length !== 10) { + response.json({ success: false, error: 'invalid_number' }) + return + } + if (phoneNumber === '5550000000') { + response.json({ success: false, error: 'busy' }) + return + } + mockPayphoneCall = { + answeredAt: Math.floor(Date.now() / 1000), + elapsedSeconds: 0, + id: `payphone-${Date.now()}`, + otherNumber: phoneNumber, + state: 'connected', + totalCost: 0, + } + response.json({ success: true, data: mockPayphoneCall }) + return + } + if (endpoint === 'payphone:hangup') { + mockPayphoneCall = null + response.json({ success: true }) + return + } + if (endpoint === 'payphone:close') { + mockPayphoneCall = null + response.json({ success: true }) + return + } if (endpoint === 'notes:list') { response.json({ success: true, data: mockNotes }) return @@ -8149,9 +8361,14 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true }) return } - response.json({ success: true }) + console.error(`[NUI] Missing browser mock for ${endpoint}`) + response.json({ success: false, error: 'mock_endpoint_missing' }) }) -app.listen(port, () => { - console.log(`Mock NUI server listening on http://localhost:${port}`) -}) +if (require.main === module) { + app.listen(port, () => { + console.log(`Mock NUI server listening on http://localhost:${port}`) + }) +} + +module.exports = { app } diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs new file mode 100644 index 0000000..46897c5 --- /dev/null +++ b/frontend/testserver/smoke.cjs @@ -0,0 +1,354 @@ +const assert = require('node:assert/strict') +const { once } = require('node:events') + +const { app } = require('./index.cjs') + +const browserDataRequests = [ + ['development:bootstrap', {}], + ['account:devices', {}], + ['banking:overview', {}], + ['billing:overview', {}], + ['billing:list', { filter: 'all', limit: 20, offset: 0 }], + ['calendar:list', { endsAt: 4_102_444_800, startsAt: 0 }], + ['calls:recents', {}], + ['companies:list', {}], + ['companies:my-requests', { limit: 20, offset: 0 }], + ['companies:work-context', {}], + ['companies:work-queue', { limit: 20, offset: 0 }], + ['contacts:list', {}], + ['crewlink:bootstrap', {}], + ['crewlink:live', {}], + ['crewlink:nearby', {}], + ['darkchat:bootstrap', {}], + ['easyshare:bootstrap', {}], + ['easyshare:own-contact', {}], + ['feather:bootstrap', {}], + ['feather:feed', { limit: 20 }], + ['feather:explore', { limit: 20 }], + ['flare:bootstrap', {}], + ['fliptok:bootstrap', {}], + ['fliptok:feed', { limit: 20 }], + ['fliptok:discover', { limit: 20 }], + ['fliptok:activities', {}], + ['gallery:list', {}], + ['garage:vehicles', {}], + ['garage:valet-state', {}], + ['housing:overview', {}], + ['housing:key-candidates', { action: 'give' }], + ['mail:counts', {}], + ['mail:list', { folder: 'inbox' }], + ['map:getPlayerCoords', {}], + ['map:markers', {}], + ['marketplace:counts', {}], + ['marketplace:list', {}], + ['marketplace:list-own', {}], + ['marketplace:list-inquiries', {}], + ['marketplace:profile', {}], + ['messages:conversations', {}], + ['messages:gifs', { query: 'party' }], + ['music:bootstrap', {}], + ['notes:list', {}], + ['pages:list', {}], + ['pages:list-own', {}], + ['pages:profile', {}], + ['picstagram:bootstrap', {}], + ['picstagram:feed', { limit: 20 }], + ['picstagram:explore', { limit: 20 }], + ['picstagram:saved', {}], + ['picstagram:stories', {}], + ['picstagram:activities', {}], + ['radio:get', {}], + ['skyride:bootstrap', {}], + ['skyride:history', {}], + ['skyride:get-player-coords', {}], + ['weather:get', {}], +] + +async function post(baseUrl, endpoint, body = {}) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + assert.equal(response.status, 200, endpoint) + return response.json() +} + +async function expectSuccess(baseUrl, endpoint, body = {}, data = false) { + const result = await post(baseUrl, endpoint, body) + assert.equal(result.success, true, `${endpoint}: ${result.error ?? 'failed'}`) + if (data) assert.notEqual(result.data, undefined, `${endpoint}: missing data`) + return result.data +} + +async function verifyStatefulActions(baseUrl) { + const noteId = `browser-note-${Date.now()}` + let notes = await expectSuccess( + baseUrl, + 'notes:create', + { + body: 'Created by the browser mock smoke test.', + id: noteId, + title: 'Browser test', + }, + true, + ) + assert( + notes.some((note) => note.id === noteId), + 'notes:create did not persist', + ) + notes = await expectSuccess( + baseUrl, + 'notes:update', + { + body: 'Updated browser test note.', + id: noteId, + title: 'Browser test updated', + }, + true, + ) + assert.equal( + notes.find((note) => note.id === noteId)?.title, + 'Browser test updated', + ) + notes = await expectSuccess(baseUrl, 'notes:delete', { id: noteId }, true) + assert( + !notes.some((note) => note.id === noteId), + 'notes:delete did not persist', + ) + + const contact = await expectSuccess( + baseUrl, + 'contacts:save', + { name: 'Browser Tester', phoneNumber: '5552223333' }, + true, + ) + await expectSuccess( + baseUrl, + 'contacts:favorite', + { favorite: true, id: contact.id }, + true, + ) + let contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true) + assert.equal(contacts.find((item) => item.id === contact.id)?.favorite, true) + await expectSuccess(baseUrl, 'contacts:delete', { id: contact.id }) + contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true) + assert( + !contacts.some((item) => item.id === contact.id), + 'contacts:delete did not persist', + ) + + const event = await expectSuccess( + baseUrl, + 'calendar:create', + { + allDay: false, + description: 'Stateful browser mock check', + endsAt: 2_000_003_600, + location: 'Legion Square', + reminderMinutes: 15, + startsAt: 2_000_000_000, + title: 'Browser test event', + }, + true, + ) + let events = await expectSuccess( + baseUrl, + 'calendar:list', + { endsAt: 4_102_444_800, startsAt: 0 }, + true, + ) + const storedEvent = events.find((item) => item.id === event.id) + assert(storedEvent, 'calendar:create did not persist') + await expectSuccess(baseUrl, 'calendar:update', { + ...storedEvent, + endsAt: storedEvent.endsAt / 1000, + startsAt: storedEvent.startsAt / 1000, + title: 'Updated browser test event', + }) + events = await expectSuccess( + baseUrl, + 'calendar:list', + { endsAt: 4_102_444_800, startsAt: 0 }, + true, + ) + assert.equal( + events.find((item) => item.id === event.id)?.title, + 'Updated browser test event', + ) + await expectSuccess(baseUrl, 'calendar:delete', { id: event.id }) + + const marker = await expectSuccess( + baseUrl, + 'map:create-marker', + { + color: '#2dd4bf', + coords: { x: 215.2, y: -810.1, z: 30.7 }, + icon: 'pin', + label: 'Browser test marker', + }, + true, + ) + let markers = await expectSuccess(baseUrl, 'map:markers', {}, true) + assert( + markers.some((item) => item.id === marker.id), + 'map:create-marker did not persist', + ) + await expectSuccess(baseUrl, 'map:delete-marker', { id: marker.id }) + markers = await expectSuccess(baseUrl, 'map:markers', {}, true) + assert( + !markers.some((item) => item.id === marker.id), + 'map:delete-marker did not persist', + ) + + const bankingBefore = await expectSuccess( + baseUrl, + 'banking:overview', + {}, + true, + ) + const bankingAfter = await expectSuccess( + baseUrl, + 'banking:transfer', + { amount: 125, phoneNumber: '5551110001' }, + true, + ) + assert.equal(bankingAfter.bank, bankingBefore.bank - 125) + + let radio = await expectSuccess( + baseUrl, + 'radio:connect', + { frequency: 42.5, secondaryFrequency: 7.25 }, + true, + ) + assert.equal(radio.frequency, 42.5) + radio = await expectSuccess(baseUrl, 'radio:set-volume', { volume: 44 }, true) + assert.equal(radio.volume, 44) + await expectSuccess(baseUrl, 'radio:disconnect') + + const playlistState = await expectSuccess( + baseUrl, + 'music:create-playlist', + { name: 'Browser Test Mix' }, + true, + ) + const playlist = playlistState.playlists.find( + (item) => item.name === 'Browser Test Mix', + ) + assert(playlist, 'music:create-playlist did not persist') + await expectSuccess( + baseUrl, + 'music:rename-playlist', + { id: playlist.id, name: 'Updated Browser Mix' }, + true, + ) + await expectSuccess( + baseUrl, + 'music:delete-playlist', + { id: playlist.id }, + true, + ) + + await expectSuccess(baseUrl, 'sim:eject') + let bootstrap = await expectSuccess( + baseUrl, + 'development:bootstrap', + {}, + true, + ) + assert.equal(bootstrap.device.sim, null) + const simConfirmation = await post(baseUrl, 'sim:insert', { + imei: '356938035643810', + }) + assert.deepEqual(simConfirmation, { + error: 'confirmation_required', + success: false, + }) + await expectSuccess(baseUrl, 'sim:insert', { + confirmed: true, + imei: '356938035643810', + }) + bootstrap = await expectSuccess(baseUrl, 'development:bootstrap', {}, true) + assert.equal(bootstrap.device.sim.number, '5559876543') + + const payphoneCall = await expectSuccess( + baseUrl, + 'payphone:dial', + { phoneNumber: '5551110001' }, + true, + ) + assert.equal(payphoneCall.state, 'connected') + await expectSuccess(baseUrl, 'payphone:hangup') + + const draft = await expectSuccess( + baseUrl, + 'mail:save-draft', + { + body: 'Browser test body', + recipients: ['alex@ifruit.com'], + subject: 'Browser test mail', + }, + true, + ) + const storedDraft = await expectSuccess( + baseUrl, + 'mail:get-draft', + { id: draft.id }, + true, + ) + assert.equal(storedDraft.subject, 'Browser test mail') + await expectSuccess(baseUrl, 'mail:delete-draft', { id: draft.id }) +} + +async function main() { + const server = app.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + const baseUrl = `http://127.0.0.1:${address.port}` + + try { + for (const [endpoint, body] of browserDataRequests) { + await expectSuccess(baseUrl, endpoint, body, true) + } + + await verifyStatefulActions(baseUrl) + + const lifecycleEndpoints = [ + 'camera:setActive', + 'camera:setFacing', + 'camera:setFlash', + 'camera:setFocus', + 'camera:setOrientation', + 'camera:setZoom', + 'close', + 'custom-app:lifecycle', + 'device:notification-open', + 'notification:focus', + 'sim:picker-close', + 'ui:opened', + 'ui:ready', + ] + for (const endpoint of lifecycleEndpoints) { + await expectSuccess(baseUrl, endpoint) + } + + const unknown = await post(baseUrl, 'development:missing-mock', {}) + assert.deepEqual(unknown, { + error: 'mock_endpoint_missing', + success: false, + }) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + + console.log( + `Verified ${browserDataRequests.length} browser data endpoints and stateful app actions.`, + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 0592b08..355fe30 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -20,6 +20,13 @@ Config.Phone = { DeviceName = "iFruit Phone", } +Config.TestData = { + Enabled = true, + Command = "phonetestdata", + AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone + AdminGroups = { "admin", "superadmin" }, +} + Config.CustomApps = { Enabled = true, BundledApps = true, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 71fa784..cbc8538 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1,5 +1,10 @@ Locales["en"] = { CommandDescription = "Open your phone.", + TestData = { + CommandDescription = "Create or refresh test content in every data-driven phone app.", + Success = "Test content is ready. Your iFruit login is {email}. Reopen the phone to refresh every app.", + Failed = "Test content could not be created. Check the server console for details.", + }, FlipTokCommand = { usage = "Usage: /{command} <@handle> [on|off]", noPermission = "You do not have permission to manage FlipTok verification.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index fb4cf85..ab0e6f8 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -89,6 +89,7 @@ server_scripts { 'source/server/calendar.lua', 'source/server/music.lua', 'source/server/radio.lua', + 'source/server/testdata.lua', } files { diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index a0ef5fa..8c97ab3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -328,6 +328,15 @@ if Config.Phone.DevelopmentCommand then end, false) end +RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail) + local locale = get_locale().TestData + local message = success and locale.Success or locale.Failed + if success and type(detail) == "string" and detail ~= "" then + message = message:gsub("{email}", detail) + end + Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000) +end) + RegisterNUICallback("ui:ready", function(_, cb) Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true }) TriggerEvent("sky_phone:client:nuiReady") @@ -765,6 +774,9 @@ CreateThread(function() if Config.Phone.DevelopmentCommand then TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription) end + if Config.TestData.Enabled then + TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, get_locale().TestData.CommandDescription) + end end) AddEventHandler("onResourceStop", function(resource_name) @@ -782,4 +794,7 @@ AddEventHandler("onResourceStop", function(resource_name) if Config.Phone.DevelopmentCommand then TriggerEvent("chat:removeSuggestion", "/" .. Config.Command) end + if Config.TestData.Enabled then + TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command) + end end) diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua new file mode 100644 index 0000000..49864b7 --- /dev/null +++ b/sky_phone/source/server/testdata.lua @@ -0,0 +1,964 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +if not Config.TestData.Enabled then + return +end + +local photo_urls = { + city = "https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80", + car = "https://images.unsplash.com/photo-1493238792000-8113da705763?auto=format&fit=crop&w=1200&q=80", + beach = "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1200&q=80", + portrait = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=800&q=80", +} +local video_url = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" +local seed_attempts = {} + +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 stable_uuid(seed) + local rows = Bridge.Database.Query([[ + SELECT LOWER(CONCAT( + SUBSTR(MD5(?), 1, 8), '-', SUBSTR(MD5(?), 9, 4), '-', + SUBSTR(MD5(?), 13, 4), '-', SUBSTR(MD5(?), 17, 4), '-', SUBSTR(MD5(?), 21, 12) + )) AS `id` + ]], { seed, seed, seed, seed, seed }) + local id = rows[1] and rows[1].id + if type(id) ~= "string" or #id ~= 36 then + error("[sky_phone] Test data could not generate a stable UUID.") + end + return id +end + +local function seed_hash(seed) + local rows = Bridge.Database.Query("SELECT LEFT(MD5(?), 12) AS `value`", { seed }) + local value = rows[1] and rows[1].value + if type(value) ~= "string" or #value ~= 12 then + error("[sky_phone] Test data could not generate a stable account suffix.") + end + return value +end + +local function database_uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local id = rows[1] and rows[1].id + if type(id) ~= "string" then + error("[sky_phone] Test data could not generate a database UUID.") + end + return id +end + +local function ensure_account(email) + Bridge.Database.Query( + "INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES (?, ?)", + { email, "sky-phone-test" } + ) + local rows = Bridge.Database.Query( + "SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `email` = ? LIMIT 1", + { email } + ) + if not rows[1] then + error(("[sky_phone] Test data account '%s' could not be loaded."):format(email)) + end + rows[1].id = tonumber(rows[1].id) + return rows[1] +end + +local function ensure_media(account_id, remote_id, url, media_type) + local rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_media` + WHERE `account_id` = ? AND `remote_id` = ? + ORDER BY `id` LIMIT 1 + ]], { account_id, remote_id }) + if rows[1] then + Bridge.Database.Query( + "UPDATE `sky_phone_media` SET `url` = ?, `media_type` = ? WHERE `id` = ?", + { url, media_type, rows[1].id } + ) + return tonumber(rows[1].id) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`) + VALUES (?, ?, ?, ?) + ]], { account_id, url, remote_id, media_type }) + rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_media` + WHERE `account_id` = ? AND `remote_id` = ? + ORDER BY `id` DESC LIMIT 1 + ]], { account_id, remote_id }) + if not rows[1] then + error("[sky_phone] Test media could not be created.") + end + return tonumber(rows[1].id) +end + +local function reserve_sim(owner_identifier, firstname, lastname) + local rows = Bridge.Database.Query([[ + SELECT `id`, `phone_number`, `sim_type` + FROM `sky_phone_sims` + WHERE `owner_identifier` = ? AND `is_virtual` = 0 + ORDER BY `created_at` LIMIT 1 + ]], { owner_identifier }) + if rows[1] then + return rows[1] + end + + local sim_id + local number = SkyPhoneSimNumber.Reserve(database_uuid, function(candidate) + if SkyPhoneCompanies.IsServiceNumber(candidate) then + return false + end + sim_id = database_uuid() + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_sims` + (`id`, `phone_number`, `sim_type`, `is_virtual`, `owner_identifier`, + `owner_firstname`, `owner_lastname`, `registered_at`) + VALUES (?, ?, 'registered', 0, ?, ?, ?, CURRENT_TIMESTAMP) + ]], { sim_id, candidate, owner_identifier, firstname, lastname }) + return affected_rows(result) == 1 + end, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + error("[sky_phone] Test data could not reserve a SIM number.") + end + return { id = sim_id, phone_number = number, sim_type = "registered" } +end + +local function ensure_bot(label, email_local, imei, firstname, lastname) + local account = ensure_account(email_local .. "@" .. Config.Mail.Domain) + local sim = reserve_sim("sky_phone:testbot:" .. label, firstname, lastname) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_devices` (`imei`, `account_id`, `sim_id`, `device_name`) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`), `sim_id` = VALUES(`sim_id`) + ]], { imei, account.id, sim.id, firstname .. "'s iFruit" }) + return { + account = account, + sim = sim, + imei = imei, + name = firstname .. " " .. lastname, + } +end + +local function ensure_numeric_profile(table_name, account_id) + local rows = Bridge.Database.Query( + ("SELECT `id` FROM `%s` WHERE `account_id` = ? LIMIT 1"):format(table_name), + { account_id } + ) + if not rows[1] then + error(("[sky_phone] Test profile in '%s' could not be loaded."):format(table_name)) + end + return tonumber(rows[1].id) +end + +local function ensure_string_profile(table_name, account_id) + local rows = Bridge.Database.Query( + ("SELECT `id` FROM `%s` WHERE `account_id` = ? LIMIT 1"):format(table_name), + { account_id } + ) + if not rows[1] or type(rows[1].id) ~= "string" then + error(("[sky_phone] Test profile in '%s' could not be loaded."):format(table_name)) + end + return rows[1].id +end + +local function seed_core(context) + local account_id = context.account.id + local bot = context.bot_one + local alarms = { + { + id = "test-weekday", + enabled = true, + time = "07:30", + note = "Phone QA", + sound = "radar", + weekdays = { 1, 2, 3, 4, 5 }, + lastTriggeredMinute = nil, + }, + { + id = "test-weekend", + enabled = false, + time = "10:00", + note = "Car Meet", + sound = "chimes", + weekdays = { 0, 6 }, + lastTriggeredMinute = nil, + }, + } + local games = { + snake = { highScore = 42, speed = "fast" }, + memory = { + best = { + small = { moves = 12, timeMs = 42000 }, + medium = { moves = 28, timeMs = 96000 }, + }, + soundEnabled = true, + }, + minesweeper = { + best = { quick = { timeMs = 31000 }, classic = { timeMs = 124000 } }, + elapsedMs = 0, + game = nil, + soundEnabled = true, + }, + ["number-merge"] = { bestScore = 8192, game = nil, highestTile = 1024, soundEnabled = true }, + ["tower-stack"] = { highHeight = 23, highScore = 4750, soundEnabled = true }, + ["sky-flappy"] = { design = "neon", highScore = 18, soundEnabled = true }, + ["neon-drop"] = { bestLines = 14, bestScore = 12600, soundEnabled = true }, + } + Bridge.Database.Query([[ + INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) + VALUES (?, 'alarms', ?) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1 + ]], { context.imei, json.encode(alarms) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) + VALUES (?, 'games', ?) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1 + ]], { context.imei, json.encode(games) }) + + local contact_id = stable_uuid(context.key .. ":contact:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_contacts` + (`id`, `contact_id`, `account_id`, `name`, `notes`, `organization`, `phone_number`, `favorite`) + VALUES (?, ?, ?, 'Alex Rivera', 'Test contact for calls and messages.', 'Downtown Cab Co.', ?, 1) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `notes` = VALUES(`notes`), + `organization` = VALUES(`organization`), `phone_number` = VALUES(`phone_number`), `favorite` = 1 + ]], { contact_id, contact_id, account_id, bot.sim.phone_number }) + + local sms_one = stable_uuid(context.key .. ":sms:one") + local sms_two = stable_uuid(context.key .. ":sms:two") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `body`, `read_at`, `created_at`) + VALUES (?, ?, ?, ?, ?, 'Willkommen auf dem Testserver! Alle Apps sind jetzt befüllt.', NULL, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 8 MINUTE)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL + ]], { sms_one, bot.sim.id, context.sim.id, bot.sim.phone_number, context.sim.phone_number }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `body`, `read_at`, `created_at`) + VALUES (?, ?, ?, ?, ?, 'Perfekt, ich teste gerade Nachrichten und Kontakte.', CURRENT_TIMESTAMP, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = CURRENT_TIMESTAMP + ]], { sms_two, context.sim.id, bot.sim.id, context.sim.phone_number, bot.sim.phone_number }) + + local call_id = stable_uuid(context.key .. ":call:missed") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_calls` + (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, + `started_at`, `ended_at`, `duration_seconds`) + VALUES (?, ?, ?, ?, ?, 'missed', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), 0) + ON DUPLICATE KEY UPDATE `status` = 'missed', `duration_seconds` = 0 + ]], { call_id, bot.sim.id, context.sim.id, bot.sim.phone_number, context.sim.phone_number }) + Bridge.Database.Query("DELETE FROM `sky_phone_call_entries` WHERE `call_id` = ? AND `account_id` = ?", { call_id, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_call_entries` + (`call_id`, `account_id`, `direction`, `status`, `other_number`, `created_at`) + VALUES (?, ?, 'incoming', 'missed', ?, DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR)) + ]], { call_id, account_id, bot.sim.phone_number }) + + local note_one = stable_uuid(context.key .. ":note:checklist") + local note_two = stable_uuid(context.key .. ":note:ideas") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_notes` (`id`, `account_id`, `title`, `body`, `pinned`) + VALUES (?, ?, 'Phone Test-Checkliste', 'Kontakte\nNachrichten\nAnrufe\nSocial Apps\nMarktplatz\nFirmen', 1) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`), `pinned` = 1 + ]], { note_one, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_notes` (`id`, `account_id`, `title`, `body`, `pinned`) + VALUES (?, ?, 'Ideen für später', 'DarkChat testen und eine SkyRide-Fahrt bewerten.', 0) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`) + ]], { note_two, account_id }) + + local mail_id = stable_uuid(context.key .. ":mail:welcome") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_mail_messages` + (`id`, `sender_account_id`, `recipients`, `subject`, `body`, `created_at`) + VALUES (?, ?, ?, 'Willkommen beim iFruit-Test', + 'Hallo! Diese Nachricht gehört zu deinem reproduzierbaren Ingame-Testdatensatz.', + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)) + ON DUPLICATE KEY UPDATE `recipients` = VALUES(`recipients`), `subject` = VALUES(`subject`), `body` = VALUES(`body`) + ]], { mail_id, bot.account.id, json.encode({ context.account.email }) }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_mail_entries` (`message_id`, `account_id`, `folder`) + VALUES (?, ?, 'inbox') + ]], { mail_id, account_id }) + local draft_id = stable_uuid(context.key .. ":mail:draft") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_mail_drafts` (`id`, `account_id`, `recipients`, `subject`, `body`) + VALUES (?, ?, ?, 'Entwurf: Testfeedback', 'Hier kann ich später mein Testfeedback ergänzen.') + ON DUPLICATE KEY UPDATE `recipients` = VALUES(`recipients`), `subject` = VALUES(`subject`), `body` = VALUES(`body`) + ]], { draft_id, account_id, json.encode({ bot.account.email }) }) + + Bridge.Database.Query( + "DELETE FROM `sky_phone_bank_transactions` WHERE `owner_identifier` = ? AND `reference` LIKE 'sky-phone-test:%'", + { context.identifier } + ) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_bank_transactions` (`owner_identifier`, `kind`, `amount`, `label`, `reference`, `created_at`) + VALUES (?, 'deposit', 2500, 'Test paycheck', 'sky-phone-test:paycheck', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)), + (?, 'withdrawal', 85, 'Los Santos Customs', 'sky-phone-test:repair', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 HOUR)), + (?, 'transfer_in', 420, 'Alex Rivera', 'sky-phone-test:transfer', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 MINUTE)) + ]], { context.identifier, context.identifier, context.identifier }) + + local invoice_id = stable_uuid(context.key .. ":invoice:repair") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_billing_invoices` + (`id`, `recipient_identifier`, `issuer_identifier`, `issuer_account`, `issuer_label`, + `title`, `description`, `amount`, `currency`, `status`, `due_at`) + VALUES (?, ?, 'sky_phone:testbot:mechanic', 'society_mechanic', 'Los Santos Customs', + 'Vehicle inspection', 'Test invoice for the Billing app.', 750, '$', 'open', + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `amount` = VALUES(`amount`), `status` = 'open', `read_at` = NULL + ]], { invoice_id, context.identifier }) + Bridge.Database.Query( + "DELETE FROM `sky_phone_billing_events` WHERE `invoice_id` = ? AND `note` = 'Generated by the phone test data command.'", + { invoice_id } + ) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`, `note`) + VALUES (?, 'created', 'sky_phone:testbot:mechanic', 'Generated by the phone test data command.') + ]], { invoice_id }) + + local event_id = stable_uuid(context.key .. ":calendar:meeting") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_calendar_events` + (`id`, `account_id`, `title`, `note`, `starts_at`, `ends_at`, `reminder_minutes`) + VALUES (?, ?, 'Phone QA Session', 'Alle Apps im Spiel durchtesten.', + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 25 HOUR), 30) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `note` = VALUES(`note`), + `starts_at` = VALUES(`starts_at`), `ends_at` = VALUES(`ends_at`), `reminder_minutes` = 30, + `reminded_at` = NULL + ]], { event_id, account_id }) + + local marker_one = stable_uuid(context.key .. ":marker:lsc") + local marker_two = stable_uuid(context.key .. ":marker:pier") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_map_markers` (`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`) + VALUES (?, ?, 'Los Santos Customs', 'orange', -337.3, -136.9, 39.0) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `color` = VALUES(`color`), + `position_x` = VALUES(`position_x`), `position_y` = VALUES(`position_y`), `position_z` = VALUES(`position_z`) + ]], { marker_one, context.imei }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_map_markers` (`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`) + VALUES (?, ?, 'Del Perro Pier', 'blue', -1649.7, -1078.3, 13.0) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `color` = VALUES(`color`), + `position_x` = VALUES(`position_x`), `position_y` = VALUES(`position_y`), `position_z` = VALUES(`position_z`) + ]], { marker_two, context.imei }) + + local song_id = stable_uuid(context.key .. ":music:song") + local playlist_id = stable_uuid(context.key .. ":music:playlist") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_youtube_songs` (`id`, `account_id`, `video_id`, `title`, `artist`) + VALUES (?, ?, 'dQw4w9WgXcQ', 'Never Gonna Give You Up', 'Rick Astley') + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `artist` = VALUES(`artist`) + ]], { song_id, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `name`) + VALUES (?, ?, 'Test Drive Mix') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`) + ]], { playlist_id, account_id }) + Bridge.Database.Query("DELETE FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?", { playlist_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlist_items` (`playlist_id`, `source`, `song_id`, `position`) + VALUES (?, 'youtube', ?, 1) + ]], { playlist_id, song_id }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_radio_profiles` + (`identifier`, `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`) + VALUES (?, ?, ?, 100.1, 101.5, 'QA', ?) + ON DUPLICATE KEY UPDATE `history` = VALUES(`history`), `settings` = VALUES(`settings`), + `primary_frequency` = VALUES(`primary_frequency`), `secondary_frequency` = VALUES(`secondary_frequency`) + ]], { + context.identifier, + json.encode({ 100.1, 101.5, 99.9 }), + json.encode({ volume = 65, notifications = true, autoRejoin = false }), + context.player_name, + }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_easyshare_preferences` (`device_imei`, `visibility`) + VALUES (?, 'everyone') ON DUPLICATE KEY UPDATE `visibility` = 'everyone' + ]], { context.imei }) + local transfer_id = stable_uuid(context.key .. ":easyshare:transfer") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_easyshare_transfers` + (`id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`, `content_type`, + `payload`, `status`, `progress`, `completed_at`) + VALUES (?, ?, ?, 'Alex Rivera', ?, 'contact', ?, 'completed', 100, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `status` = 'completed', + `progress` = 100, `completed_at` = CURRENT_TIMESTAMP + ]], { + transfer_id, + bot.imei, + context.imei, + context.player_name, + json.encode({ name = "Mia Chen", phoneNumber = context.bot_two.sim.phone_number }), + }) +end + +local function seed_marketplace_and_pages(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + local own_handle = "tester" .. tostring(account_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, 'QA profile generated in game.', ?) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, context.player_name, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, 'Alex Rivera', 'Trusted test seller.', ?) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { bot_id, context.media.bot_portrait }) + + local bot_listing = stable_uuid(context.key .. ":market:bot-listing") + local own_listing = stable_uuid(context.key .. ":market:own-listing") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_listings` + (`id`, `seller_account_id`, `title`, `description`, `category`, `item_condition`, + `price_type`, `price`, `district`, `show_phone`, `phone_number`, `status`, `expires_at`) + VALUES (?, ?, 'Sultan RS - Test Vehicle', 'Clean test listing with negotiable price.', + 'vehicles', 'very_good', 'negotiable', 42000, 'los_santos', 1, ?, 'active', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 30 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { bot_listing, bot_id, context.bot_one.sim.phone_number }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_listings` + (`id`, `seller_account_id`, `title`, `description`, `category`, `item_condition`, + `price_type`, `price`, `district`, `status`, `expires_at`) + VALUES (?, ?, 'QA Headset', 'A listing owned by the test user.', 'electronics', 'used', + 'fixed', 350, 'los_santos', 'active', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 30 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { own_listing, account_id }) + local car_gradient = ("url(%s)"):format(json.encode(photo_urls.car)) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_images` (`listing_id`, `media_id`, `gradient`, `sort_order`) + VALUES (?, ?, ?, 1) + ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`), `gradient` = VALUES(`gradient`) + ]], { bot_listing, tostring(context.media.bot_car), car_gradient }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_marketplace_favorites` (`account_id`, `listing_id`) VALUES (?, ?) + ]], { account_id, bot_listing }) + local inquiry_id = stable_uuid(context.key .. ":market:inquiry") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_inquiries` + (`id`, `listing_id`, `seller_account_id`, `buyer_account_id`, `offer_amount`, + `offer_proposer_account_id`, `offer_status`, `offer_revision`) + VALUES (?, ?, ?, ?, 40000, ?, 'pending', 1) + ON DUPLICATE KEY UPDATE `offer_amount` = 40000, `offer_proposer_account_id` = VALUES(`offer_proposer_account_id`), + `offer_status` = 'pending', `offer_revision` = 1 + ]], { inquiry_id, bot_listing, bot_id, account_id, account_id }) + Bridge.Database.Query("DELETE FROM `sky_phone_marketplace_messages` WHERE `inquiry_id` = ?", { inquiry_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_messages` (`inquiry_id`, `sender_account_id`, `body`, `read_at`) + VALUES (?, ?, 'Ist der Sultan noch verfügbar?', CURRENT_TIMESTAMP), + (?, ?, 'Ja, gerne Probefahrt in Burton.', NULL) + ]], { inquiry_id, account_id, inquiry_id, bot_id }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_marketplace_offers` (`inquiry_id`, `proposer_account_id`, `amount`) + VALUES (?, ?, 40000) + ]], { inquiry_id, account_id }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`) + VALUES (?, ?, 'Lokale Tests, Events und Angebote.', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, own_handle, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`) + VALUES (?, 'alex.local', 'News aus Los Santos.', ?) + ON DUPLICATE KEY UPDATE `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { bot_id, context.media.bot_portrait }) + local page_post = stable_uuid(context.key .. ":pages:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_posts` + (`id`, `account_id`, `source_type`, `title`, `body`, `category`, `district`) + VALUES (?, ?, 'personal', 'Car Meet am Pier', 'Heute Abend findet ein offenes Test-Car-Meet statt.', + 'event', 'los_santos') + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`) + ]], { page_post, bot_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_images` (`post_id`, `media_id`, `gradient`, `sort_order`) + VALUES (?, ?, ?, 1) + ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`), `gradient` = VALUES(`gradient`) + ]], { page_post, tostring(context.media.bot_car), car_gradient }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_pages_reactions` (`post_id`, `account_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { page_post, account_id, page_post, account_id }) +end + +local function seed_social_apps(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + local bot_two_id = context.bot_two.account.id + local suffix = tostring(account_id) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, ?, ?, 'Ingame QA account', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), + `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { + stable_uuid(context.key .. ":pic:user"), account_id, "tester" .. suffix, context.player_name, + context.media.user_portrait, + }) + local pic_user = ensure_string_profile("sky_phone_picstagram_profiles", account_id) + local pic_bot_seed = stable_uuid("sky_phone:testbot:pic:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`) + VALUES (?, ?, 'alex.rivera', 'Alex Rivera', 'Cars, city lights and test content.', ?, 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`), `verified` = 1 + ]], { pic_bot_seed, bot_id, context.media.bot_portrait }) + local pic_bot = ensure_string_profile("sky_phone_picstagram_profiles", bot_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`) + ]], { context.imei, pic_user }) + local pic_post = stable_uuid(context.key .. ":pic:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_posts` (`id`, `profile_id`, `caption`, `location`) + VALUES (?, ?, 'Night drive through Los Santos #test', 'Vinewood') + ON DUPLICATE KEY UPDATE `caption` = VALUES(`caption`), `location` = VALUES(`location`), `status` = 'published' + ]], { pic_post, pic_bot }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_post_media` (`post_id`, `media_id`, `position`) + VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`) + ]], { pic_post, context.media.bot_city }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_follows` (`follower_id`, `following_id`, `status`) + VALUES (?, ?, 'accepted') + ]], { pic_user, pic_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { pic_post, pic_user, pic_post, pic_user }) + local pic_comment = stable_uuid(context.key .. ":pic:comment") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `body`) + VALUES (?, ?, ?, 'Sieht richtig gut aus!') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'visible' + ]], { pic_comment, pic_post, pic_user }) + local story_id = stable_uuid(context.key .. ":pic:story") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_stories` (`id`, `profile_id`, `media_id`, `body`, `expires_at`) + VALUES (?, ?, ?, 'Test story live', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { story_id, pic_bot, context.media.bot_city }) + local pic_activity = stable_uuid(context.key .. ":pic:activity") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, 'like') + ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { pic_activity, pic_user, pic_bot, pic_post }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_profiles` (`account_id`, `handle`, `display_name`, `bio`) + VALUES (?, ?, ?, 'Testing every FlipTok feature.') + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`) + ]], { account_id, "tester" .. suffix, context.player_name }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_profiles` + (`account_id`, `handle`, `display_name`, `bio`, `account_type`, `verified`) + VALUES (?, 'mia.motion', 'Mia Chen', 'Short videos from Los Santos.', 'media', 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), `verified` = 1 + ]], { bot_two_id }) + local flip_user = ensure_numeric_profile("sky_phone_fliptok_profiles", account_id) + local flip_bot = ensure_numeric_profile("sky_phone_fliptok_profiles", bot_two_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`) + ]], { context.imei, flip_user }) + local flip_video = stable_uuid(context.key .. ":flip:video") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_videos` + (`id`, `profile_id`, `media_id`, `caption`, `location`, `view_count`, `share_count`) + VALUES (?, ?, ?, 'Flowers in motion #fyp #test', 'Mirror Park', 1842, 37) + ON DUPLICATE KEY UPDATE `caption` = VALUES(`caption`), `view_count` = 1842, + `share_count` = 37, `status` = 'published' + ]], { flip_video, flip_bot, context.media.bot_video }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) VALUES (?, ?) + ]], { flip_user, flip_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { flip_video, flip_user, flip_video, flip_user }) + local flip_comment = stable_uuid(context.key .. ":flip:comment") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`) + VALUES (?, ?, ?, 'Der Test-Clip läuft flüssig.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'visible' + ]], { flip_comment, flip_video, flip_user }) + local flip_notification = stable_uuid(context.key .. ":flip:notification") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) + VALUES (?, ?, ?, ?, 'follow') ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { flip_notification, flip_user, flip_bot, flip_video }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_profiles` (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, ?, 'Testing Feather in game.', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), + `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, "tester" .. suffix, context.player_name, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_profiles` + (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`) + VALUES (?, 'alex_updates', 'Alex Rivera', 'Los Santos live updates.', ?, 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`), `verified` = 1 + ]], { bot_id, context.media.bot_portrait }) + local feather_user = ensure_numeric_profile("sky_phone_feather_profiles", account_id) + local feather_bot = ensure_numeric_profile("sky_phone_feather_profiles", bot_id) + local feather_post = stable_uuid(context.key .. ":feather:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`) + VALUES (?, ?, 'Der neue iFruit-Testdatensatz ist live. #LosSantos #PhoneQA') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'published' + ]], { feather_post, feather_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) + VALUES (?, 'lossantos'), (?, 'phoneqa') + ]], { feather_post, feather_post }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_follows` (`follower_id`, `following_id`) VALUES (?, ?) + ]], { feather_user, feather_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'bookmark') + ]], { feather_post, feather_user, feather_post, feather_user }) + local feather_notification = stable_uuid(context.key .. ":feather:notification") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_notifications` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, 'follow') ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { feather_notification, feather_user, feather_bot, feather_post }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_profiles` + (`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`, + `avatar`, `interests`, `looking_for`) + VALUES (?, ?, 27, 'Testing Flare conversations.', 'nonbinary', 'everyone', 21, 40, 0, ?, 'friends') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `bio` = VALUES(`bio`), `interests` = VALUES(`interests`) + ]], { account_id, context.player_name:sub(1, 32), json.encode({ "cars", "music", "gaming" }) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_profiles` + (`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`, + `avatar`, `interests`, `looking_for`) + VALUES (?, 'Mia', 26, 'Coffee, sunsets and good conversations.', 'woman', 'everyone', 21, 40, 1, ?, 'friends') + ON DUPLICATE KEY UPDATE `bio` = VALUES(`bio`), `interests` = VALUES(`interests`) + ]], { bot_two_id, json.encode({ "coffee", "travel", "photography" }) }) + local flare_user = ensure_numeric_profile("sky_phone_flare_profiles", account_id) + local flare_bot = ensure_numeric_profile("sky_phone_flare_profiles", bot_two_id) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`) + VALUES (?, ?, 1), (?, ?, 1) + ]], { flare_user, context.media.user_portrait, flare_bot, context.media.bot_two_portrait }) + local match_id = stable_uuid(context.key .. ":flare:match") + local account_a = math.min(account_id, bot_two_id) + local account_b = math.max(account_id, bot_two_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = `created_at` + ]], { match_id, account_a, account_b }) + local flare_message = stable_uuid(context.key .. ":flare:message") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_messages` (`id`, `match_id`, `sender_account_id`, `body`, `read_at`) + VALUES (?, ?, ?, 'Hey! Bereit für einen vollständigen App-Test?', NULL) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL + ]], { flare_message, match_id, bot_two_id }) +end + +local function seed_private_and_services(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_profiles` + (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) + VALUES (?, ?, ?, 'NightTester', 42, 'private', 1) + ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`) + ]], { account_id, ("DARK%010d"):format(account_id % 10000000000), ("INV%08d"):format(account_id % 100000000) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_profiles` + (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) + VALUES (?, 'DARK0000000001', 'INV00000001', 'GhostAlex', 17, 'full', 1) + ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`) + ]], { bot_id }) + local dark_user = ensure_numeric_profile("sky_phone_darkchat_profiles", account_id) + local dark_bot = ensure_numeric_profile("sky_phone_darkchat_profiles", bot_id) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_contacts` (`profile_id`, `contact_profile_id`, `alias_override`) + VALUES (?, ?, 'Ghost') + ]], { dark_user, dark_bot }) + local conversation_id = stable_uuid(context.key .. ":dark:conversation") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_conversations` (`id`, `disappearing_seconds`) + VALUES (?, 0) ON DUPLICATE KEY UPDATE `disappearing_seconds` = 0 + ]], { conversation_id }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`, `last_read_at`) + VALUES (?, ?, CURRENT_TIMESTAMP), (?, ?, NULL) + ]], { conversation_id, dark_user, conversation_id, dark_bot }) + local dark_one = stable_uuid(context.key .. ":dark:message:one") + local dark_two = stable_uuid(context.key .. ":dark:message:two") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_messages` (`id`, `conversation_id`, `sender_profile_id`, `body`) + VALUES (?, ?, ?, 'Willkommen im privaten Testchat.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `deleted_for_everyone` = 0 + ]], { dark_one, conversation_id, dark_bot }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_messages` + (`id`, `conversation_id`, `sender_profile_id`, `message_type`, `body`, `reactions`) + VALUES (?, ?, ?, 'emoji', '🔥', ?) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `reactions` = VALUES(`reactions`) + ]], { dark_two, conversation_id, dark_user, json.encode({ ["🔥"] = { dark_bot } }) }) + + local crew_user_seed = stable_uuid(context.key .. ":crew:user") + local crew_bot_seed = stable_uuid("sky_phone:testbot:crew:alex") + local group_id = stable_uuid(context.key .. ":crew:group") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `active_group_id`) + VALUES (?, ?, ?, NULL) + ON DUPLICATE KEY UPDATE `username` = VALUES(`username`) + ]], { crew_user_seed, account_id, ("tester%s"):format(account_id):sub(1, 20) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `active_group_id`) + VALUES (?, ?, 'alexcrew', NULL) + ON DUPLICATE KEY UPDATE `username` = VALUES(`username`) + ]], { crew_bot_seed, bot_id }) + local crew_user = ensure_string_profile("sky_phone_crewlink_profiles", account_id) + local crew_bot = ensure_string_profile("sky_phone_crewlink_profiles", bot_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_groups` + (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`, `allow_member_pings`, `overhead_allowed`) + VALUES (?, 'Phone QA Crew', 'violet', ?, ?, 1, 1) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `colour` = VALUES(`colour`) + ]], { group_id, crew_user, ("QA%s"):format(account_id):sub(1, 12) }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_crewlink_memberships` (`group_id`, `profile_id`, `role`) + VALUES (?, ?, 'owner'), (?, ?, 'member') + ]], { group_id, crew_user, group_id, crew_bot }) + Bridge.Database.Query( + "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` IN (?, ?)", + { group_id, crew_user, crew_bot } + ) + local ping_id = stable_uuid(context.key .. ":crew:ping") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_pings` + (`id`, `group_id`, `creator_profile_id`, `type`, `label`, `position_x`, `position_y`, `position_z`, `expires_at`) + VALUES (?, ?, ?, 'meeting', 'QA Treffpunkt', -337.3, -136.9, 39.0, + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 12 HOUR)) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `expires_at` = VALUES(`expires_at`) + ]], { ping_id, group_id, crew_bot }) + + local ride_user_seed = stable_uuid(context.key .. ":skyride:user") + local ride_bot_seed = stable_uuid("sky_phone:testbot:skyride:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `owner_identifier` = VALUES(`owner_identifier`) + ]], { ride_user_seed, context.identifier }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`) + VALUES (?, 'sky_phone:testbot:alex') ON DUPLICATE KEY UPDATE `owner_identifier` = VALUES(`owner_identifier`) + ]], { ride_bot_seed }) + local ride_user_rows = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skyride_profiles` WHERE `owner_identifier` = ? LIMIT 1", + { context.identifier } + ) + local ride_bot_rows = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skyride_profiles` WHERE `owner_identifier` = 'sky_phone:testbot:alex' LIMIT 1", + {} + ) + local ride_user = ride_user_rows[1] and ride_user_rows[1].id + local ride_bot = ride_bot_rows[1] and ride_bot_rows[1].id + if type(ride_user) ~= "string" or type(ride_bot) ~= "string" then + error("[sky_phone] Test SkyRide profiles could not be loaded.") + end + local ride_id = stable_uuid(context.key .. ":skyride:completed") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_rides` + (`id`, `passenger_profile_id`, `driver_profile_id`, `passenger_name`, `driver_name`, + `status`, `service_class`, `pickup_label`, `pickup_x`, `pickup_y`, `pickup_z`, + `destination_label`, `destination_x`, `destination_y`, `destination_z`, `distance_meters`, + `duration_seconds`, `price`, `payout_amount`, `currency`, `driver_vehicle_model`, + `driver_vehicle_color`, `driver_vehicle_plate`, `passenger_rating`, `rating_comment`, + `tip_amount`, `tip_status`, `accepted_at`, `arrived_at`, `started_at`, `completed_at`, `paid_out_at`) + VALUES (?, ?, ?, ?, 'Alex Rivera', 'completed', 'comfort', 'Legion Square', 215.8, -810.1, 30.7, + 'Del Perro Pier', -1649.7, -1078.3, 13.0, 6200, 540, 320, 240, '$', 'Sultan', + 'Midnight Blue', 'QA 2026', 5, 'Saubere Testfahrt.', 25, 'completed', + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 HOUR), DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 170 MINUTE), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 165 MINUTE), DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR)) + ON DUPLICATE KEY UPDATE `status` = 'completed', `passenger_rating` = 5, + `rating_comment` = 'Saubere Testfahrt.', `tip_amount` = 25, `tip_status` = 'completed' + ]], { ride_id, ride_user, ride_bot, context.player_name }) + + local company_rows = Bridge.Database.Query( + "SELECT `company_id` FROM `sky_phone_company_profiles` WHERE `company_id` = 'mechanic' LIMIT 1", + {} + ) + if company_rows[1] then + local request_id = stable_uuid(context.key .. ":company:request") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_requests` + (`id`, `company_id`, `service_id`, `customer_sim_id`, `subject`, `description`, + `status`, `customer_unread`, `company_activity_revision`, `revision`) + VALUES (?, 'mechanic', 'mechanic-repair', ?, 'Motor verliert Leistung', + 'Testauftrag mit Chatverlauf und Status.', 'waiting_customer', 1, 3, 3) + ON DUPLICATE KEY UPDATE `subject` = VALUES(`subject`), `description` = VALUES(`description`), + `status` = 'waiting_customer', `customer_unread` = 1, `company_activity_revision` = 3, `revision` = 3 + ]], { request_id, context.sim.id }) + local request_message = stable_uuid(context.key .. ":company:message") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_request_messages` + (`id`, `request_id`, `sender_type`, `sender_identifier`, `body`) + VALUES (?, ?, 'company', 'sky_phone:testbot:mechanic', + 'Bitte komm für eine Diagnose bei Los Santos Customs vorbei.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`) + ]], { request_message, request_id }) + local request_event = stable_uuid(context.key .. ":company:event") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`, `detail`) + VALUES (?, ?, 'status', 'company', 'sky_phone:testbot:mechanic', 'in_progress', 'waiting_customer', + 'Waiting for the test customer.') + ON DUPLICATE KEY UPDATE `detail` = VALUES(`detail`) + ]], { request_event, request_id }) + end +end + +local function seed_for_source(source) + local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item) + local phone_slot = slots[1] + if not phone_slot then + error(("[sky_phone] Test data source %s has no phone item."):format(source)) + end + local imei, device_error = SkyPhone.EnsureDevice(source, phone_slot) + if not imei then + error(("[sky_phone] Test data could not resolve the phone device: %s"):format(tostring(device_error))) + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + error("[sky_phone] Test data could not resolve the player identifier.") + end + + local player_name = ((Bridge.Framework.GetFirstname(source) or "") .. " " + .. (Bridge.Framework.GetLastname(source) or "")):match("^%s*(.-)%s*$") + if player_name == "" then + player_name = GetPlayerName(source) or ("Player %s"):format(source) + end + local device = SkyPhone.LoadDevice(imei) + local account + if device and device.account_id then + local rows = Bridge.Database.Query( + "SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `id` = ? LIMIT 1", + { device.account_id } + ) + account = rows[1] + if not account then + error("[sky_phone] Test data found a device with a missing iFruit account.") + end + account.id = tonumber(account.id) + else + account = ensure_account("tester_" .. seed_hash(identifier) .. "@" .. Config.Mail.Domain) + Bridge.Database.Query("UPDATE `sky_phone_devices` SET `account_id` = ? WHERE `imei` = ?", { account.id, imei }) + end + + device = SkyPhone.LoadDevice(imei) + local sim + if device and device.sim_id then + local rows = Bridge.Database.Query("SELECT * FROM `sky_phone_sims` WHERE `id` = ? LIMIT 1", { device.sim_id }) + sim = rows[1] + else + sim = reserve_sim(identifier, Bridge.Framework.GetFirstname(source), Bridge.Framework.GetLastname(source)) + Bridge.Database.Query("UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?", { sim.id, imei }) + local metadata = phone_slot.metadata or {} + metadata.sim_id = sim.id + metadata.phone_number = sim.phone_number + metadata.formatted_number = SkyPhoneSimNumber.Format( + sim.phone_number, + Config.Sim.NumberGroups, + Config.Sim.NumberLength, + Config.Sim.NumberPrefix + ) + if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata) then + error("[sky_phone] Test data could not update the phone item's SIM metadata.") + end + end + + local bot_one = ensure_bot("alex", "phone.bot.alex", "990000000000001", "Alex", "Rivera") + local bot_two = ensure_bot("mia", "phone.bot.mia", "990000000000002", "Mia", "Chen") + local context = { + source = source, + identifier = identifier, + player_name = player_name, + account = account, + imei = imei, + sim = sim, + bot_one = bot_one, + bot_two = bot_two, + key = identifier .. ":" .. imei, + media = {}, + } + context.media.user_portrait = ensure_media(account.id, "test-user-portrait", photo_urls.portrait, "photo") + context.media.user_city = ensure_media(account.id, "test-user-city", photo_urls.city, "photo") + context.media.user_video = ensure_media(account.id, "test-user-video", video_url, "video") + context.media.bot_portrait = ensure_media(bot_one.account.id, "test-bot-alex-portrait", photo_urls.car, "photo") + context.media.bot_car = ensure_media(bot_one.account.id, "test-bot-alex-car", photo_urls.car, "photo") + context.media.bot_city = ensure_media(bot_one.account.id, "test-bot-alex-city", photo_urls.city, "photo") + context.media.bot_two_portrait = ensure_media(bot_two.account.id, "test-bot-mia-portrait", photo_urls.portrait, "photo") + context.media.bot_video = ensure_media(bot_two.account.id, "test-bot-mia-video", video_url, "video") + + seed_core(context) + seed_marketplace_and_pages(context) + seed_social_apps(context) + seed_private_and_services(context) + SkyPhone.RefreshSource(source) + return account.email +end + +RegisterCommand(Config.TestData.Command, function(source) + if source <= 0 then + Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") + return + end + if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then + Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + local now = os.time() + if seed_attempts[source] and now - seed_attempts[source] < 30 then + Bridge.Debug("warn", "[sky_phone] Source %s repeated the test data command too quickly.", tostring(source)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + seed_attempts[source] = now + local success, result = pcall(seed_for_source, source) + if not success then + Bridge.Debug("error", "[sky_phone] Test data seeding failed for source %s: %s", tostring(source), tostring(result)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + Bridge.Debug("info", "[sky_phone] Test data seeded for source %s.", tostring(source), { always = true }) + TriggerClientEvent("sky_phone:testdata:feedback", source, true, result) +end, false) + +AddEventHandler("playerDropped", function() + seed_attempts[source] = nil +end) + +end) From 8cdd5d3e15a7417af63c21674e0f5c477415a747 Mon Sep 17 00:00:00 2001 From: Alec Schitzkat Date: Wed, 12 Aug 2026 18:52:14 +0200 Subject: [PATCH 2/7] 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(() => {