Files
sky_phone/frontend/src/stores/phone.ts
T

1216 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
type AppNotificationPreferences,
type PhonePreferencesV1,
type WallpaperId,
} from '@/utils/preferences'
type LocaleTree = Record<string, unknown>
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
token?: string
}
const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
darkchat: {
name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message',
signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.',
security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.',
noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat',
connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity',
message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation',
emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More',
voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to',
messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}',
timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days',
copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report',
contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared',
myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.',
unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat',
reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted',
microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.',
errors: {
not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.',
},
},
messages: {
name: 'Messages',
newMessage: 'New message from {sender}',
compose: 'New Message',
search: 'Search',
to: 'To:',
message: 'Message',
send: 'Send',
details: 'Details',
filterUnread: 'Show Unread Messages',
smsLabel: 'Text Message · SMS',
photo: 'Photo',
gif: 'GIF',
video: 'Video',
attachPhoto: 'Attach Photo',
takePhoto: 'Take Photo',
attachGif: 'Attach GIF',
attachVideo: 'Attach Video',
photos: 'Photos',
gifs: 'GIFs',
videos: 'Videos',
noPhotos: 'Take a photo first to attach it here.',
noVideos: 'Record a video in Camera first.',
searchGifs: 'Search GIPHY',
loadMore: 'Load More',
retryGifs: 'Try Again',
moreActions: 'More Actions',
contactDetails: 'Contact Details',
contactName: 'Name',
phoneNumber: 'Phone Number',
call: 'Call',
messageAction: 'Message',
addContact: 'Add Contact',
deleteContact: 'Delete Contact',
selectedCount: '{count} Selected',
deleteSelected: 'Delete',
contactSaveFailed: 'The contact could not be saved.',
contactDeleteFailed: 'The contact could not be deleted.',
callFailed: 'The call could not be started.',
emoji: 'Emoji',
voiceMessage: 'Audio Message',
recordVoice: 'Record Audio',
playAudio: 'Play Audio',
pauseAudio: 'Pause Audio',
recording: 'Recording',
stopAndSend: 'Stop and Send',
cancelRecording: 'Cancel Recording',
sending: 'Sending...',
delivered: 'Delivered',
notDelivered: 'Not Delivered',
microphoneUnavailable: 'The microphone is unavailable.',
recordingTooLarge: 'The recording is too large.',
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to send and receive messages.',
noMessages: 'No Messages',
noMessagesBody: 'Start a conversation with a phone number.',
noResults: 'No Results',
today: 'Today',
yesterday: 'Yesterday',
errors: {
invalid_number: 'Enter a valid phone number.',
invalid_message: 'Enter a message.',
invalid_voice: 'The audio message is invalid.',
invalid_attachment: 'The attachment is invalid.',
media_provider_unconfigured: 'Photo uploads are not configured.',
capture_provider_unavailable: 'The screenshot resource is unavailable.',
capture_failed: 'The photo could not be captured.',
video_provider_unavailable: 'Video capture requires the screencapture resource.',
video_capture_failed: 'The video could not be recorded.',
recording_in_progress: 'A video is already being recorded.',
recording_not_found: 'No active video recording was found.',
gif_provider_unconfigured: 'GIF search is not configured.',
gif_provider_unauthorized: 'The configured GIPHY API key is invalid.',
gif_provider_rate_limited: 'GIPHY is busy. Try again in a moment.',
gif_provider_failed: 'GIF search is temporarily unavailable.',
self_message: 'You cannot message your own number.',
recipient_not_found: 'That number is unavailable.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
default: 'The message could not be sent.',
},
},
appStore: {
name: 'App Store',
get: 'GET',
open: 'OPEN',
installing: 'Installing',
searchPlaceholder: 'Search apps and games',
appsTitle: 'Built-in Apps',
gamesTitle: 'Games',
selected: 'Apps available for your Sky Phone',
tabs: {
apps: 'Apps',
games: 'Games',
search: 'Search',
},
},
phone: {
name: 'Phone',
recents: 'Recents',
contacts: 'Contacts',
keypad: 'Keypad',
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to make calls.',
noRecents: 'No Recent Calls',
noContacts: 'No Contacts',
searchContacts: 'Search Contacts',
addContact: 'New Contact',
editContact: 'Edit Contact',
contactName: 'Name',
phoneNumber: 'Phone Number',
call: 'Call',
calling: 'calling...',
incoming: 'Incoming Call',
incomingDirection: 'Incoming',
outgoingDirection: 'Outgoing',
connected: 'Connected',
missed: 'Missed',
declined: 'Declined',
busy: 'Busy',
unavailable: 'Unavailable',
noAnswer: 'No Answer',
cancelled: 'Cancelled',
disconnected: 'Disconnected',
sim_removed: 'SIM Removed',
completed: 'Call Ended',
answer: 'Answer',
decline: 'Decline',
hangup: 'End',
addToContacts: 'Add to Contacts',
deleteContact: 'Delete Contact',
replaceTitle: 'Replace SIM?',
replaceBody: 'The current SIM will be returned to your inventory.',
choosePhone: 'Choose a Phone',
choosePhoneBody: 'Select the phone that should receive {number}.',
emptyPhone: 'No SIM inserted',
errors: {
invalid_contact: 'Enter a name and valid phone number.',
invalid_number: 'Enter a valid phone number.',
no_sim: 'This phone has no SIM card.',
airplane_mode: 'Turn off Airplane Mode to make calls.',
self_call: 'You cannot call your own number.',
busy: 'The line is busy.',
rate_limited: 'Too many calls. Try again in a minute.',
voice_unavailable: 'The configured phone voice service is unavailable.',
inventory_full: 'There is no room for the ejected SIM card.',
operation_in_progress:
'Another phone operation is already in progress.',
sim_request_expired:
'The SIM selection expired. Use the SIM card again.',
sim_not_owned: 'That SIM card is no longer in your inventory.',
phone_not_owned: 'That phone is no longer in your inventory.',
metadata_unsupported:
'The configured inventory cannot store unique SIM metadata.',
request_failed: 'The phone request failed.',
default: 'The phone request failed.',
},
},
banking: {
name: 'Banking',
welcome: 'Welcome back',
totalBalance: 'Total Balance',
recentPeriod: 'in recent activity',
actions: 'Banking actions',
send: 'Send',
accounts: 'Accounts',
bankAccount: 'Bank account',
cash: 'Cash',
latestTransactions: 'Latest Transactions',
allTransactions: 'All Transactions',
viewAll: 'View all',
noTransactions: 'Your banking activity will appear here.',
home: 'Home',
activity: 'Activity',
chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
navigation: 'Banking navigation',
incoming: 'Incoming',
outgoing: 'Outgoing',
refresh: 'Refresh banking data',
unavailable: 'Banking unavailable',
tryAgain: 'Try Again',
playerId: 'Player ID',
playerIdPlaceholder: 'Enter the recipient ID',
amount: 'Amount',
amountPlaceholder: 'Enter an amount',
transactions: {
deposit: 'Cash deposit',
withdrawal: 'Cash withdrawal',
transfer_in: 'Incoming transfer',
transfer_out: 'Outgoing transfer',
},
forms: {
transfer: {
title: 'Send money',
body: 'Transfer money from your bank account to an online player.',
submit: 'Send transfer',
},
},
errors: {
invalid_request: 'Enter a valid whole amount and player ID.',
insufficient_funds: 'There is not enough money in this account.',
target_not_found: 'The recipient is not online.',
self_transfer: 'You cannot send money to yourself.',
rate_limited: 'Please wait before making another transaction.',
transfer_failed: 'The transfer could not be completed.',
banking_unavailable: 'Banking is currently unavailable.',
request_failed: 'The banking request failed.',
default: 'The banking request failed.',
},
},
calculator: { name: 'Calculator' },
snake: {
name: 'Snake',
backToMenu: 'Back to game menu',
board: 'Snake game board',
controls: 'Direction controls',
directions: {
down: 'Move down',
left: 'Move left',
right: 'Move right',
up: 'Move up',
},
gameOver: 'Game Over',
highScore: 'High Score',
menu: 'Main Menu',
pause: 'Pause game',
paused: 'Paused',
readyBody: 'Collect fruit, grow longer, and stay clear of every wall.',
readyTitle: 'Ready to play?',
restart: 'Play Again',
resume: 'Resume game',
score: 'Score',
speed: 'Speed',
speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' },
start: 'Start Game',
swipeHint: 'Use the arrow keys to steer',
},
memory: {
name: 'Memory',
backToMenu: 'Back to board selection',
board: 'Memory card board',
chooseBody: 'Find every matching pair with as few moves as possible.',
chooseTitle: 'Choose a board',
completeEyebrow: 'Board cleared',
completeTitle: 'Great memory!',
difficulties: { large: 'Expert', medium: 'Classic', small: 'Quick' },
eyebrow: 'Matching game',
hiddenCard: 'Hidden card',
menu: 'Board Selection',
moves: 'Moves',
mute: 'Mute game sounds',
noBest: 'No best score yet',
playAgain: 'Play Again',
restart: 'Restart',
revealedCard: 'Revealed card',
time: 'Time',
unmute: 'Turn on game sounds',
},
numberMerge: {
name: '2048',
backToMenu: 'Back to game menu',
best: 'Best',
board: '2048 game board',
cancel: 'Keep Current Game',
confirmBody: 'Your current board will be replaced. This cannot be undone.',
confirmNew: 'Start New Game',
confirmTitle: 'Replace current game?',
continueGame: 'Continue Game',
controls: 'Move controls',
directions: {
down: 'Move tiles down',
left: 'Move tiles left',
right: 'Move tiles right',
up: 'Move tiles up',
},
eyebrow: 'Number puzzle',
gameOverBody: 'No empty spaces or matching neighbours remain.',
gameOverTitle: 'No more moves',
highest: 'Highest Tile',
howToBody: 'Swipe to combine matching tiles. Each pair becomes one larger tile.',
howToExample: '2 + 2 = 4 · 4 + 4 = 8 · … · 1024 + 1024 = 2048',
howToGoal: 'Goal: create the 2048 tile before the board has no moves left.',
howToTitle: 'How to play',
keepPlaying: 'Keep Playing',
mainMenu: 'Main Menu',
menuBody: 'Slide matching numbers together and work your way up to 2048.',
menuTitle: 'Build the 2048 tile',
mute: 'Mute game sounds',
newGame: 'New Game',
score: 'Score',
swipeHint: 'Swipe the board, use the buttons, or press arrow keys / WASD',
unmute: 'Turn on game sounds',
wonBody: 'You reached the legendary tile. Continue climbing or begin again.',
wonTitle: 'You made 2048!',
},
minesweeper: {
name: 'Minesweeper',
backToMenu: 'Back to game menu',
board: 'Minesweeper board',
chooseBody:
'Reveal every safe field. Numbers show how many mines touch that field.',
chooseTitle: 'Choose a minefield',
difficulties: { classic: 'Classic', expert: 'Expert', quick: 'Quick' },
emptyCell: 'Revealed empty field',
eyebrow: 'Mine puzzle',
flaggedCell: 'Flagged field',
gameHint: 'Tap to reveal · Hold or right-click to flag',
hiddenCell: 'Hidden field',
longPressHint: 'Tap to reveal · Hold to place a flag',
lostBody:
'A mine was hidden there. Mark suspicious fields and try again.',
lostTitle: 'Mine triggered',
mainMenu: 'Main Menu',
mineCell: 'Revealed mine',
mines: 'Mines',
mute: 'Mute game sounds',
noBest: 'No best time yet',
numberCell: 'Revealed field with {count} adjacent mines',
playAgain: 'Play Again',
restart: 'Restart',
resume: 'Continue Game',
time: 'Time',
unmute: 'Turn on game sounds',
wonBody: 'Minefield cleared in {time}.',
wonTitle: 'Field cleared!',
},
towerStack: {
name: 'Tower Stack',
backToMenu: 'Back to game menu',
bestHeight: 'Best Height',
bestScore: 'Best Score',
blocks: 'Blocks',
eyebrow: 'Timing challenge',
finalScore: 'Final Score',
gameHint: 'Tap the playfield to place the moving block',
gameOver: 'Tower collapsed',
height: 'Height',
mainMenu: 'Main Menu',
menuBody:
'Stop each moving block above the tower. Only the overlapping part remains.',
menuTitle: 'How high can you build?',
mute: 'Mute game sounds',
newGame: 'Start New Game',
pause: 'Pause or resume game',
paused: 'Paused',
perfect: 'PERFECT!',
placeBlock: 'Place moving block',
playAgain: 'Play Again',
ready: 'Ready to stack?',
resume: 'Continue Game',
score: 'Score',
start: 'Start Game',
tapHint: 'Every tap places one block · perfect hits earn bonus points',
unmute: 'Turn on game sounds',
},
skyFlappy: {
name: 'Sky Flappy',
backToMenu: 'Back to game menu',
best: 'Best',
design: 'Sky design',
designs: { dawn: 'Dawn', neon: 'Neon', storm: 'Storm' },
eyebrow: 'Sky challenge',
firstTap: 'Tap to launch',
flap: 'Fly upward',
gameHint: 'Tap anywhere in the sky to fly upward',
gameOver: 'Flight ended',
highScore: 'High Score',
mainMenu: 'Main Menu',
menuBody:
'Guide the sky bird through every tower opening without touching the edges.',
menuTitle: 'Fly between the towers',
mute: 'Mute game sounds',
pause: 'Pause or resume game',
paused: 'Paused',
playAgain: 'Fly Again',
points: 'Points',
ready: 'Ready for takeoff?',
resume: 'Continue Flight',
score: 'Score',
start: 'Start Flight',
tapHint: 'Tap to flap · gravity pulls the bird down',
unmute: 'Turn on game sounds',
},
neonDrop: {
name: 'Neon Drop',
backToMenu: 'Back to game menu',
bestLines: 'Best Lines',
bestScore: 'Best Score',
board: 'Neon Drop reactor board',
controls: 'Block controls',
eyebrow: 'Energy block puzzle',
gameHint: 'Swipe the reactor or use the five controls',
gameOver: 'Reactor overloaded',
hardDrop: 'Drop block immediately',
howToBody:
'Fill complete horizontal rows to dissolve them and keep the reactor clear.',
howToTitle: 'Charge the reactor',
left: 'Move block left',
level: 'Level',
lines: 'Lines',
mainMenu: 'Main Menu',
menuBody:
'Rotate and place falling energy shapes. Every cleared row increases your charge.',
menuTitle: 'Build complete energy lines',
mute: 'Mute game sounds',
newGame: 'Start New Game',
next: 'Next',
pause: 'Pause or resume game',
paused: 'Paused',
playAgain: 'Play Again',
points: 'Points',
ready: 'Reactor ready',
resume: 'Continue Game',
right: 'Move block right',
rotate: 'Rotate block',
score: 'Score',
softDrop: 'Move block down',
start: 'Start Reactor',
unmute: 'Turn on game sounds',
},
citymarkt: {
name: 'CityMarkt',
eyebrow: 'Los Santos marketplace',
tabs: { discover: 'Discover', search: 'Search', sell: 'Sell', inbox: 'Inbox', profile: 'Me' },
categories: {
vehicles: 'Vehicles', property: 'Property', electronics: 'Electronics', clothing: 'Clothing',
tools: 'Tools', leisure: 'Leisure', services: 'Services', jobs: 'Jobs', wanted: 'Wanted', other: 'Other',
},
conditions: { new: 'New', very_good: 'Very good', used: 'Used', defective: 'Defective' },
priceTypes: { fixed: 'Fixed price', negotiable: 'Negotiable', free: 'Free' },
offerStatus: {
pending: 'Open',
accepted: 'Accepted',
declined: 'Declined',
countered: 'Negotiated',
},
districts: {
los_santos: 'Los Santos', vinewood: 'Vinewood', vespucci: 'Vespucci',
south_los_santos: 'South Los Santos', sandy_shores: 'Sandy Shores',
paleto_bay: 'Paleto Bay', blaine_county: 'Blaine County',
},
status: { active: 'Active', reserved: 'Reserved', sold: 'Sold', expired: 'Expired', removed: 'Removed' },
reportReasons: { prohibited: 'Prohibited item', fraud: 'Suspected fraud', spam: 'Spam', offensive: 'Offensive content', other: 'Other' },
searchPlaceholder: 'What are you looking for?', allCategories: 'All categories', allDistricts: 'All districts',
sortNewest: 'Newest first', sortPriceAsc: 'Lowest price', sortPriceDesc: 'Highest price',
freshOffers: 'Fresh offers', offers: 'offers', noListings: 'No offers found',
noListingsBody: 'Try another search or category.', noDistrict: 'No district',
free: 'Free', negotiablePrice: '${price} negotiable', money: '${price}',
hoursAgo: '{count}h ago', daysAgo: '{count}d ago', photos: 'photos', activeListings: 'active listings',
signInTitle: 'Sign in to iFruit', signInBody: 'Use Settings to sign in before selling, saving or messaging.',
noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.',
myListings: 'My listings', favorites: 'Favorites', noProfileListings: 'Nothing here yet',
phone: 'Phone', contactSeller: 'Contact seller', messagePlaceholder: 'Hi, is this still available?',
signInToMessage: 'Sign in to your iFruit account to send a message.',
edit: 'Edit', makeActive: 'Make active', markSold: 'Mark sold', remove: 'Remove',
createListing: 'Create listing', editListing: 'Edit listing', save: 'Save', step: 'Step {current} of {total}', next: 'Next', previous: 'Back', publish: 'Publish',
addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.',
chooseGallery: 'Choose from gallery', chooseGalleryBody: 'Use photos saved on this phone.',
takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.',
selectedPhotos: 'Selected photos', gallery: 'Gallery', camera: 'Camera', takePhoto: 'Take photo',
cameraHint: 'Take as many photos as you need. Each shot is added to the listing automatically.',
noPhoto: 'No photo available', noPhotoBody: 'The seller did not add a photo to this listing.',
noPhotoOptional: 'You can publish without a photo or add one from the gallery or camera.',
previousPhoto: 'Previous photo', nextPhoto: 'Next photo', photo: 'Photo',
removePhoto: 'Remove photo {number}', photoLimit: 'You can add up to six photos.',
describeOffer: 'Describe your offer', title: 'Title', description: 'Description', category: 'Category', condition: 'Condition',
characterCount: '{current} / {maximum} characters', minimumCharacters: 'min. {minimum}',
priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District',
showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.',
writeMessage: 'Write a message', reserveForBuyer: 'Reserve for this buyer', statusChanged: 'Listing updated.',
offer: 'Offer', counterOffer: 'Counteroffer', makeOffer: 'Make an offer',
offeredByYou: 'You sent this offer.', offeredToYou: 'You received this offer.',
acceptOffer: 'Accept', declineOffer: 'Decline', negotiateOffer: 'Counter',
offerFor: 'Your offer for', offerAmount: 'Offer amount', sendOffer: 'Send offer',
offerSent: 'Your offer was sent.', offerAccepted: 'Offer accepted.', offerDeclined: 'Offer declined.',
reportListing: 'Report listing', reportWhy: 'Why are you reporting this?', reportDetails: 'Add details (optional)',
sendReport: 'Send report', blockSeller: 'Block seller', reported: 'Report sent.', blocked: 'Seller blocked.',
newMessage: 'New CityMarkt message from {sender}',
newOffer: '{sender} offered ${price}.',
offerAcceptedNotification: '{sender} accepted your ${price} offer.',
offerRejectedNotification: '{sender} declined your ${price} offer.',
errors: {
invalid_listing: 'Check the listing details.', invalid_price: 'Enter a valid price.',
invalid_images: 'Choose valid photos from this phone.', phone_unavailable: 'Insert a SIM or hide your number.',
listing_limit: 'You have reached the active listing limit.', listing_not_found: 'This listing is no longer available.',
invalid_message: 'Enter a valid message.', inquiry_not_found: 'This conversation is no longer available.',
invalid_offer: 'Enter a valid whole-number offer.', invalid_offer_response: 'This offer action is invalid.',
offer_listing_unavailable: 'This listing is no longer available for offers.',
offer_closed: 'This negotiation is already complete.', offer_waiting: 'Wait for the other person to respond.',
offer_not_allowed: 'You cannot make an offer right now.', offer_not_actionable: 'This offer can no longer be changed.',
offer_conflict: 'The offer changed. Please try again.',
blocked: 'Messages are blocked between these accounts.', already_reported: 'You already reported this listing.',
rate_limited: 'Too many requests. Try again shortly.', not_authenticated: 'Sign in to your iFruit account first.',
conflict: 'The listing changed. Open it again.', request_failed: 'CityMarkt is temporarily unavailable.',
default: 'The CityMarkt request failed.',
},
},
localPages: {
name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos',
heroTitle: 'What is happening nearby?', heroBody: 'Discover places, people and local tips.',
searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories',
allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago',
categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' },
discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts',
noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached',
signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes',
location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing',
newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.',
title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...',
photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.',
cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day',
errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' },
},
map: {
name: 'Map',
controls: 'Map controls',
currentLocation: 'Current Location',
imageError: 'The map image could not be loaded.',
switchStyle: 'Switch Map Type',
styles: {
default: 'Default Map',
satellite: 'Satellite Map',
atlas: 'Atlas Map',
roads: 'Road Map',
},
},
weather: {
name: 'Weather',
now: 'Now',
today: 'Today',
refresh: 'Refresh weather',
details: 'Weather details',
feelsLike: 'Feels Like',
wind: 'Wind',
humidity: 'Humidity',
rain: 'Precipitation',
hourly: '24-Hour Forecast',
daily: '7-Day Forecast',
unavailable: 'Weather is unavailable',
stale: 'Showing the last available weather update.',
tryAgain: 'Try Again',
regions: {
los_santos: 'Los Santos',
blaine_county: 'Blaine County',
cayo_perico: 'Cayo Perico',
},
conditions: {
sunny: 'Sunny',
clear: 'Clear',
partly_cloudy: 'Partly Cloudy',
cloudy: 'Cloudy',
rain: 'Rain',
thunder: 'Thunderstorms',
fog: 'Foggy',
snow: 'Snow',
},
summaries: {
sunny: 'Bright skies throughout the current period.',
clear: 'Calm and clear conditions across the region.',
partly_cloudy: 'Sunshine mixed with passing clouds.',
cloudy: 'Cloud cover will remain over the region.',
rain: 'Wet conditions are moving through the area.',
thunder: 'Stormy conditions with heavy bursts of rain.',
fog: 'Reduced visibility in low-lying areas.',
snow: 'Cold conditions with snow across the region.',
},
},
camera: {
name: 'Camera',
flash: 'Flash',
flip: 'Flip camera',
landscape: 'Switch to landscape',
portrait: 'Switch to portrait',
photo: 'Photo',
video: 'Video',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Gallery',
takePhoto: 'Take photo',
startRecording: 'Start recording',
stopRecording: 'Stop recording',
saved: 'Saved to Gallery.',
zoom: 'Set camera zoom to {zoom}',
errors: {
cancelled: 'Capture cancelled.',
capture_failed: 'Unable to capture the game view.',
invalid_media_type: 'The uploaded media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Camera uploads are not configured.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
owner_changed: 'The active phone account changed during upload.',
rate_limited: 'Too many media actions. Try again shortly.',
request_failed: 'The camera request failed.',
request_timeout: 'The media service timed out.',
unsupported: 'Video recording is not supported.',
upload_failed: 'The media upload failed.',
upload_timeout: 'The media upload timed out.',
},
},
calendar: {
name: 'Calendar',
today: 'Today',
calendars: 'Calendars',
calendar: 'Calendar',
calendarName: 'iFruit',
searchPlaceholder: 'Search events',
previousMonth: 'Previous month',
nextMonth: 'Next month',
eyebrow: 'Your time. Clearly planned.',
schedule: 'Schedule',
event: 'Event',
appointment: 'Appointment',
newEvent: 'New event',
editEvent: 'Edit event',
deleteEvent: 'Delete event',
details: 'Event details',
title: 'Title',
titlePlaceholder: 'What is planned?',
date: 'Date',
starts: 'Starts',
ends: 'Ends',
reminder: 'Reminder',
note: 'Note',
notePlaceholder: 'Add details, an address or anything to remember...',
noEvents: 'Nothing planned',
noEventsBody: 'This day is still free. Add an event whenever you are ready.',
signInTitle: 'Your iFruit calendar',
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
reminderNotification: 'Upcoming: {title}',
views: {
compact: 'Compact',
stacked: 'Stacked',
details: 'Details',
list: 'List',
},
reminders: {
none: 'No reminder',
atStart: 'At start time',
tenMinutes: '10 minutes before',
thirtyMinutes: '30 minutes before',
oneHour: '1 hour before',
oneDay: '1 day before',
},
errors: {
invalid_event: 'Enter a title and make sure the end is after the start.',
invalid_range: 'This calendar period is invalid.',
conflict: 'This event changed on another phone. Open it again.',
rate_limited: 'Too many changes. Try again shortly.',
not_authenticated: 'Sign in to your iFruit account first.',
request_failed: 'Calendar is temporarily unavailable.',
default: 'The calendar request failed.',
},
},
clock: {
name: 'Clock',
lap: 'Lap',
minutes: 'Minutes',
add: 'Add alarm',
location: 'Los Santos',
tabs: {
world: 'Clock',
alarm: 'Alarm',
stopwatch: 'Stopwatch',
timer: 'Timer',
},
alarm: {
add: 'Add Alarm',
edit: 'Edit Alarm',
ringing: 'Alarm',
time: 'Time',
hours: 'Hours',
repeat: 'Repeat',
note: 'Note',
notePlaceholder: 'Alarm',
sound: 'Sound',
delete: 'Delete Alarm',
never: 'Never',
everyDay: 'Every Day',
weekdays: 'Weekdays',
weekends: 'Weekends',
days: {
sunday: 'Sunday',
monday: 'Monday',
tuesday: 'Tuesday',
wednesday: 'Wednesday',
thursday: 'Thursday',
friday: 'Friday',
saturday: 'Saturday',
},
daysShort: {
sunday: 'Sun',
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
},
sounds: {
radar: 'Radar',
beacon: 'Beacon',
chimes: 'Chimes',
apex: 'Apex',
aurora: 'Aurora',
circuit: 'Circuit',
constellation: 'Constellation',
daybreak: 'Daybreak',
uplift: 'Uplift',
},
},
timer: {
time: 'Timer duration',
hours: 'Hours',
hoursShort: 'hr',
minutes: 'Minutes',
minutesShort: 'min',
seconds: 'Seconds',
secondsShort: 'sec',
note: 'Note',
notePlaceholder: 'Timer',
sound: 'Sound',
ringing: 'Timer',
},
},
mail: {
name: 'Mail',
login: 'Sign In',
register: 'Create Account',
registerLink: 'Register',
loginTitle: 'iFruit Mail',
loginBody: 'Sign in to use your shared iFruit mailbox.',
registerBody: 'Choose your new @ifruit.com address.',
localPart: 'Email address',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm password',
accountEyebrow: 'Your iFruit account',
emailPlaceholder: 'name@ifruit.com',
passwordPlaceholder: 'Enter password',
passwordWarning:
'Use an in-character password. Do not reuse a real-world password.',
mailboxes: 'Mailboxes',
inbox: 'Inbox',
sent: 'Sent',
drafts: 'Drafts',
trash: 'Trash',
compose: 'New Message',
recipients: 'To',
recipientHint: 'Separate up to 10 addresses with commas.',
recipientPlaceholder: 'name@ifruit.com',
subject: 'Subject',
subjectPlaceholder: "What's this about?",
body: 'Message',
messagePlaceholder: 'Write a message...',
search: 'Search mail',
messages: 'messages',
noMessageContent: 'No message content',
noMail: 'No Mail',
noMailBody: 'Messages in this mailbox will appear here.',
noResults: 'No Results',
noResultsBody: 'Try a different search.',
untitled: '(no subject)',
loadMore: 'Load More',
logout: 'Sign Out',
from: 'From',
to: 'To',
reply: 'Reply',
replyAll: 'Reply All',
forward: 'Forward',
formatBold: 'Bold',
formatItalic: 'Italic',
formatBulletList: 'Bullet list',
formatNumberedList: 'Numbered list',
formatQuote: 'Quote',
undo: 'Undo',
redo: 'Redo',
markRead: 'Read',
markUnread: 'Mark as Unread',
delete: 'Delete',
moveToTrash: 'Move to Trash',
restore: 'Restore',
deleteForever: 'Delete Forever',
emptyTrash: 'Empty Trash',
emptyTrashTitle: 'Empty Trash?',
emptyTrashBody: 'Every message in Trash will be permanently deleted.',
deleteDraft: 'Delete Draft',
sentSuccess: 'Message sent.',
newMessage: 'New mail from {sender}',
passwordsMismatch: 'Passwords do not match.',
errors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
invalid_credentials: 'Email or password is incorrect.',
email_taken: 'That iFruit address is already registered.',
rate_limited: 'Too many attempts. Try again in a minute.',
invalid_message: 'Add a valid recipient and a subject or message.',
recipient_not_found: 'One or more recipients do not exist.',
invalid_draft: 'This draft contains invalid content.',
not_authenticated: 'Your mail session has ended. Sign in again.',
request_failed: 'Mail is temporarily unavailable.',
invalid_request: 'The mail request was invalid.',
default: 'The mail request failed.',
},
},
notes: {
name: 'Notes',
note: 'Note',
back: 'Back',
actions: 'Note actions',
newNote: 'New Note',
searchPlaceholder: 'Search Notes',
title: 'Title',
titlePlaceholder: 'Note title',
body: 'Note',
bodyPlaceholder: 'Start writing...',
untitled: 'Untitled',
noText: 'No additional text',
emptyBadge: 'ON DEVICE',
emptyTitle: 'No Notes',
emptyBody: 'Create a note to keep important details close at hand.',
noResults: 'No Results',
noResultsBody: 'Try searching for a different word or phrase.',
pin: 'Pin note',
unpin: 'Unpin note',
deleteNote: 'Delete note',
},
photos: {
name: 'Gallery',
count: '{count} items',
loading: 'Loading Gallery...',
emptyTitle: 'No Photos or Videos',
emptyBody: 'Captures from Camera will appear here.',
photo: 'Photo',
video: 'Video',
photoAlt: 'Gallery photo',
videoAlt: 'Gallery video',
delete: 'Delete media',
deleteTitle: 'Delete Media?',
deleteBody: 'This photo or video will be permanently deleted.',
deleted: 'Media deleted.',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
resetZoom: 'Reset zoom',
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
errors: {
cancelled: 'The media action was cancelled.',
capture_failed: 'Unable to capture the game view.',
invalid_media_type: 'The media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Gallery uploads are not configured.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
owner_changed: 'The active phone account changed.',
rate_limited: 'Too many media actions. Try again shortly.',
request_failed: 'The Gallery request failed.',
request_timeout: 'The media service timed out.',
unsupported: 'This media format is not supported.',
upload_failed: 'The media upload failed.',
upload_timeout: 'The media upload timed out.',
},
},
settings: {
name: 'Settings',
searchPlaceholder: 'Search',
airplaneMode: 'Airplane Mode',
streamerMode: 'Streamer Mode',
wallpaper: 'Wallpaper',
on: 'On',
off: 'Off',
accountName: 'iFruit Account',
accountDetail: 'Important Data & Cloud',
accountLocalDetail: 'Not signed in',
accountCloudDetail: 'Important data syncs through iFruit Cloud',
accountLoginBody:
'Sign in to keep your important data safe. Without an iFruit Account, it will be lost with the phone.',
accountInformation: 'Account Information',
accountStatus: 'Account Status',
accountStatusValue: 'Active',
accountStorage: 'Cloud Storage',
accountStorageValue: 'On Device',
accountPurchases: 'Media & Purchases',
accountPurchasesValue: 'Available',
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
general: 'General Settings',
appearance: 'Appearance',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
notificationDuration: 'Notification Duration',
seconds: '{seconds} seconds',
ringtoneVolume: 'Ringtone Volume',
notificationVolume: 'Notification Volume',
ringtone: 'Ringtone',
notificationSound: 'Notification Sound',
appearanceMode: 'Appearance Mode',
automatic: 'Automatic',
light: 'Light',
dark: 'Dark',
phoneScale: 'Phone Scale',
phoneFrame: 'Phone Frame',
about: 'About',
deviceName: 'Device Name',
deviceNameValue: 'Sky Phone',
softwareVersion: 'Software Version',
language: 'Language',
languageValue: 'English',
localStorage: 'Local Storage',
localStorageValue: 'On Device',
deviceInformation: 'Device Information',
imei: 'IMEI',
simCard: 'SIM Card',
simNumber: 'Phone Number',
simType: 'SIM Type',
registeredSim: 'Registered',
anonymousSim: 'Anonymous',
noSim: 'No SIM',
ejectSim: 'Eject SIM',
ejectSimBody: 'Return this SIM card to your inventory?',
linkedDevices: 'Linked Devices',
thisDevice: 'This Phone',
removeDevice: 'Remove Device',
removeDeviceBody:
'Enter your iFruit password to remove this device from the account.',
signOut: 'Sign Out',
factoryReset: 'Erase All Content and Settings',
factoryResetBody:
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
factoryResetProgress: 'Erasing iFruit Phone',
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
invalid_credentials: 'Email or password is incorrect.',
email_taken: 'That iFruit address is already registered.',
rate_limited: 'Too many attempts. Try again in a minute.',
current_device: 'Sign out instead of removing the current phone.',
device_not_found: 'That device is no longer linked.',
default: 'The account request failed.',
},
back: 'Settings',
wallpaperPicker: 'Built-in Wallpapers',
toggle: {
airplaneMode: 'Toggle Airplane Mode',
streamerMode: 'Toggle Streamer Mode',
notifications: 'Toggle notifications for {app}',
notificationSounds: 'Toggle notification sounds for {app}',
},
frames: {
black: 'Black',
blue: 'Blue',
green: 'Green',
lavender: 'Lavender',
white: 'White',
},
ringtones: {
skyline: 'Skyline',
horizon: 'Horizon',
pulse: 'Pulse',
},
notificationSoundsList: {
chime: 'Chime',
signal: 'Signal',
soft: 'Soft',
},
wallpapers: {
midnight: 'Midnight wallpaper',
aurora: 'Aurora wallpaper',
ember: 'Ember wallpaper',
},
},
},
Common: {
add: 'Add',
cancel: 'Cancel',
clear: 'Clear',
close: 'Close',
back: 'Back',
delete: 'Delete',
done: 'Done',
edit: 'Edit',
home: 'Home',
pause: 'Pause',
phone: 'Phone',
phoneStatus: 'Phone status',
reset: 'Reset',
loading: 'Loading',
search: 'Search',
save: 'Save',
send: 'Send',
start: 'Start',
stop: 'Stop',
},
Notifications: { now: 'now' },
LockScreen: {
label: 'Lock Screen',
flashlight: 'Flashlight',
camera: 'Camera',
swipeUp: 'Swipe up to open',
},
Home: {
appLibrary: 'App Library',
appLibrarySearch: 'Search apps',
allApps: 'All Apps',
apps: 'Apps',
dock: 'Dock',
noApps: 'No apps found',
removeApp: 'Remove {app} from Home Screen',
page: 'Page',
pages: 'Home screen pages',
groups: {
suggestions: 'Suggestions',
recentlyAdded: 'Recently Added',
games: 'Games',
productivity: 'Productivity',
shopping: 'Shopping',
social: 'Social Networks',
utilities: 'Utilities',
},
widgets: {
label: 'Widgets',
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
calendar: { event: 'No more events today' },
battery: { label: 'Phone' },
media: {
title: 'Night Drive',
artist: 'Sky Radio',
play: 'Play',
pause: 'Pause',
},
},
},
}
function getByPath(source: LocaleTree, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (
typeof current !== 'object' ||
current === null ||
Array.isArray(current)
)
return undefined
return (current as LocaleTree)[segment]
}, source)
}
export const usePhoneStore = defineStore('phone', {
state: () => ({
cameraLandscape: false,
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record<string, number>,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
isDarkMode(state): boolean {
if (state.preferences.settings.appearanceMode === 'dark') return true
if (state.preferences.settings.appearanceMode === 'light') return false
return state.systemDarkMode
},
},
actions: {
close(): void {
this.cameraLandscape = false
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
Object.entries(device.data).map(([key, value]) => [
key,
value?.revision ?? 0,
]),
)
this.preferences = parsePhonePreferences(
JSON.stringify(device.data.settings?.payload ?? null),
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
const queued = previous.then(async () => {
const response = await nuiCall<{ revision: number }>('device:save', {
namespace,
payload,
revision: this.deviceRevisions[namespace] ?? 0,
})
if (response.success && response.data) {
this.deviceRevisions[namespace] = response.data.revision
}
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(namespace) === tracked)
namespaceQueues.delete(namespace)
})
namespaceQueues.set(namespace, tracked)
},
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
},
setCameraLandscape(landscape: boolean): void {
this.cameraLandscape = landscape
},
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
this.launchOrigin = origin
},
setAppNotification(
appId: LaunchablePhoneAppId,
key: keyof AppNotificationPreferences,
value: boolean,
): void {
this.preferences.settings.notifications[appId][key] = value
this.saveDeviceNamespace('settings', this.preferences)
},
setPreference<K extends keyof PhonePreferencesV1['settings']>(
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
this.preferences.settings[key] = value
this.saveDeviceNamespace('settings', this.preferences)
},
setSystemDarkMode(value: boolean): void {
this.systemDarkMode = value
},
setWallpaper(wallpaper: WallpaperId): void {
this.preferences.settings.wallpaper = wallpaper
this.saveDeviceNamespace('settings', this.preferences)
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
const value =
typeof translated === 'string'
? translated
: typeof fallback === 'string'
? fallback
: path
return Object.entries(replacements).reduce(
(result, [key, replacement]) =>
result.split(`{${key}}`).join(replacement),
value,
)
},
},
})