MERGE - resolve dev integration conflicts

This commit is contained in:
Dominik
2026-08-09 05:36:10 +02:00
54 changed files with 8188 additions and 307 deletions
+186
View File
@@ -0,0 +1,186 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFlipTokStore } from '@/stores/fliptok'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokProfile,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const profile: FlipTokProfile = {
account_type: 'person',
bio: '',
display_name: 'Nova',
followers: 1,
following: 2,
handle: 'nova',
id: 7,
is_following: false,
is_owner: true,
verified: false,
video_count: 1,
}
const video: FlipTokVideo = {
caption: 'Los Santos',
comment_count: 1,
comments_enabled: true,
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'video-1',
is_following: false,
is_liked: false,
is_owner: true,
is_saved: false,
like_count: 2,
location: '',
cover_time_ms: 0,
music_artist: '',
music_title: '',
music_track: '',
music_url: '',
music_volume: 0,
original_volume: 100,
profile_id: 7,
share_count: 0,
trim_end_ms: null,
trim_start_ms: 0,
url: 'https://example.com/video.webm',
verified: false,
view_count: 3,
}
const comment: FlipTokComment = {
body: 'Nice',
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'comment-1',
profile_id: 7,
verified: false,
}
const activity: FlipTokActivity = {
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'activity-1',
kind: 'follow',
profile_id: 7,
read_at: null,
verified: false,
video_id: null,
}
describe('FlipTok verification updates', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('updates the badge everywhere the profile is already visible', () => {
const store = useFlipTokStore()
store.profile = { ...profile }
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.applyVerification(7, true)
expect(store.profile.verified).toBe(true)
expect(store.feed[0].verified).toBe(true)
expect(store.searchResults[0].verified).toBe(true)
expect(store.comments[0].verified).toBe(true)
expect(store.activities[0].verified).toBe(true)
})
it('does not alter another profile', () => {
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.applyVerification(99, true)
expect(store.feed[0].verified).toBe(false)
})
it('removes a blocked creator from every visible surface', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.profileVideos = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.viewedProfile = { ...profile }
expect(await store.blockProfile(7)).toBe(true)
expect(store.feed).toEqual([])
expect(store.searchResults).toEqual([])
expect(store.profileVideos).toEqual([])
expect(store.comments).toEqual([])
expect(store.activities).toEqual([])
expect(store.viewedProfile).toBeNull()
})
it('keeps the app signed out when bootstrap has no FlipTok session', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: { authenticated: false, musicTracks: [] },
})
const store = useFlipTokStore()
expect(await store.bootstrap()).toBe(true)
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
})
it('loads the profile after a successful login', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({
success: true,
data: {
authenticated: true,
feed: { hasMore: false, items: [{ ...video }], offset: 0 },
isAdmin: false,
musicTracks: [],
profile: { ...profile },
},
})
const store = useFlipTokStore()
expect((await store.login('nova', 'password123')).success).toBe(true)
expect(store.authenticated).toBe(true)
expect(store.profile?.handle).toBe('nova')
expect(nuiCall).toHaveBeenNthCalledWith(1, 'fliptok:login', {
handle: 'nova',
password: 'password123',
})
})
it('clears the complete local session after logout', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.authenticated = true
store.profile = { ...profile }
store.feed = [{ ...video }]
store.activities = [{ ...activity }]
expect((await store.logout()).success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('fliptok:logout')
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
expect(store.activities).toEqual([])
})
})
+235
View File
@@ -0,0 +1,235 @@
import { defineStore } from 'pinia'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokMusicTrack,
FlipTokPage,
FlipTokProfile,
FlipTokProfilePage,
FlipTokReport,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useFlipTokStore = defineStore('fliptok', {
state: () => ({
activities: [] as FlipTokActivity[],
authenticated: false,
comments: [] as FlipTokComment[],
feed: [] as FlipTokVideo[],
isAdmin: false,
loading: false,
musicTracks: [] as FlipTokMusicTrack[],
mode: 'for-you' as 'for-you' | 'following',
profile: null as FlipTokProfile | null,
profileVideos: [] as FlipTokVideo[],
reports: [] as FlipTokReport[],
searchResults: [] as FlipTokVideo[],
viewedProfile: null as FlipTokProfile | null,
}),
actions: {
applyVerification(profileId: number, verified: boolean): void {
if (this.profile?.id === profileId) this.profile.verified = verified
if (this.viewedProfile?.id === profileId)
this.viewedProfile.verified = verified
this.feed
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.searchResults
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.profileVideos
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.comments
.filter((comment) => comment.profile_id === profileId)
.forEach((comment) => {
comment.verified = verified
})
this.activities
.filter((activity) => activity.profile_id === profileId)
.forEach((activity) => {
activity.verified = verified
})
},
async bootstrap(): Promise<boolean> {
this.loading = true
const response = await nuiCall<{
authenticated: boolean
feed?: FlipTokPage
isAdmin?: boolean
musicTracks: FlipTokMusicTrack[]
profile?: FlipTokProfile
}>('fliptok:bootstrap')
this.loading = false
if (!response.success || !response.data) return false
this.authenticated = response.data.authenticated === true
this.profile = response.data.profile ?? null
this.feed = response.data.feed?.items ?? []
this.isAdmin = response.data.isAdmin === true
this.musicTracks = response.data.musicTracks ?? []
return true
},
async login(handle: string, password: string): Promise<NuiResponse> {
const response = await nuiCall('fliptok:login', { handle, password })
if (response.success) await this.bootstrap()
return response
},
async register(
displayName: string,
handle: string,
password: string,
): Promise<NuiResponse> {
const response = await nuiCall('fliptok:register', {
displayName,
handle,
password,
})
if (response.success) await this.bootstrap()
return response
},
async logout(): Promise<NuiResponse> {
const response = await nuiCall('fliptok:logout')
if (!response.success) return response
this.$reset()
return response
},
async loadFeed(mode?: 'for-you' | 'following'): Promise<boolean> {
mode ??= this.mode
this.mode = mode
this.loading = true
const response = await nuiCall<FlipTokPage>('fliptok:feed', {
mode,
offset: 0,
})
this.loading = false
if (!response.success || !response.data) return false
this.feed = response.data.items
return true
},
async discover(search: string): Promise<FlipTokVideo[]> {
const response = await nuiCall<FlipTokVideo[]>('fliptok:discover', {
search,
})
this.searchResults =
response.success && response.data ? response.data : []
return this.searchResults
},
async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise<void> {
const key = kind === 'like' ? 'is_liked' : 'is_saved'
const next = !video[key]
video[key] = next
if (kind === 'like') video.like_count += next ? 1 : -1
const response = await nuiCall('fliptok:react', {
active: next,
id: video.id,
kind,
})
if (!response.success) {
video[key] = !next
if (kind === 'like') video.like_count += next ? -1 : 1
}
},
async follow(video: FlipTokVideo): Promise<void> {
const next = !video.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: video.profile_id,
})
if (response.success)
this.feed
.filter((item) => item.profile_id === video.profile_id)
.forEach((item) => {
item.is_following = next
})
},
async followProfile(profile: FlipTokProfile): Promise<void> {
const next = !profile.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: profile.id,
})
if (!response.success) return
profile.is_following = next
profile.followers += next ? 1 : -1
this.feed
.filter((item) => item.profile_id === profile.id)
.forEach((item) => {
item.is_following = next
})
},
async loadProfile(query: {
handle?: string
profileId?: number
}): Promise<boolean> {
const response = await nuiCall<FlipTokProfilePage>(
'fliptok:profile',
query,
)
if (!response.success || !response.data) return false
this.viewedProfile = response.data.profile
this.profileVideos = response.data.videos
return true
},
showOwnProfile(): void {
this.viewedProfile = null
this.profileVideos = this.feed.filter((item) => item.is_owner)
},
async blockProfile(profileId: number): Promise<boolean> {
const response = await nuiCall('fliptok:block', { profileId })
if (!response.success) return false
this.feed = this.feed.filter((video) => video.profile_id !== profileId)
this.searchResults = this.searchResults.filter(
(video) => video.profile_id !== profileId,
)
this.comments = this.comments.filter(
(comment) => comment.profile_id !== profileId,
)
this.activities = this.activities.filter(
(activity) => activity.profile_id !== profileId,
)
this.profileVideos = this.profileVideos.filter(
(video) => video.profile_id !== profileId,
)
if (this.viewedProfile?.id === profileId) this.viewedProfile = null
return true
},
async loadComments(id: string): Promise<void> {
const response = await nuiCall<FlipTokComment[]>('fliptok:comments', {
id,
})
this.comments = response.success && response.data ? response.data : []
},
async comment(id: string, body: string): Promise<NuiResponse> {
return nuiCall('fliptok:comment', { body, id })
},
async loadActivities(): Promise<void> {
const response = await nuiCall<FlipTokActivity[]>('fliptok:activities')
this.activities = response.success && response.data ? response.data : []
if (response.success) await nuiCall('fliptok:mark-activities')
},
async loadReports(): Promise<boolean> {
const response = await nuiCall<FlipTokReport[]>('fliptok:admin-reports')
this.reports = response.success && response.data ? response.data : []
return response.success
},
async resolveReport(
id: string,
action: 'dismiss' | 'remove',
): Promise<boolean> {
const response = await nuiCall('fliptok:admin-resolve-report', {
action,
id,
})
if (response.success) await this.loadReports()
return response.success
},
},
})
+60
View File
@@ -0,0 +1,60 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMapStore } from '@/stores/map'
import type { MapMarker } from '@/types/map'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const marker: MapMarker = {
color: 'blue',
coords: { x: -75.2, y: -818.9, z: 0 },
id: 'marker-1',
label: 'Meeting point',
}
describe('map store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads persistent markers', async () => {
mockNuiCall.mockResolvedValueOnce({ data: [marker], success: true })
const map = useMapStore()
expect(await map.load()).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:markers')
})
it('adds a marker returned by the server', async () => {
mockNuiCall.mockResolvedValueOnce({ data: marker, success: true })
const map = useMapStore()
const input = {
color: marker.color,
coords: marker.coords,
label: marker.label,
}
expect((await map.create(input)).success).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:create-marker', input)
})
it('only removes a marker after server confirmation', async () => {
mockNuiCall
.mockResolvedValueOnce({ error: 'request_failed', success: false })
.mockResolvedValueOnce({ success: true })
const map = useMapStore()
map.markers = [marker]
await map.remove(marker.id)
expect(map.markers).toEqual([marker])
await map.remove(marker.id)
expect(map.markers).toEqual([])
})
})
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import type { CreateMapMarker, MapMarker } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useMapStore = defineStore('map', {
state: () => ({
error: '',
isLoading: false,
markers: [] as MapMarker[],
}),
actions: {
async load(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<MapMarker[]>('map:markers')
this.isLoading = false
if (response.success && response.data) {
this.markers = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async create(marker: CreateMapMarker): Promise<NuiResponse<MapMarker>> {
this.isLoading = true
const response = await nuiCall<MapMarker>('map:create-marker', marker)
this.isLoading = false
if (response.success && response.data) {
this.markers.push(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async remove(id: string): Promise<NuiResponse> {
this.isLoading = true
const response = await nuiCall('map:delete-marker', { id })
this.isLoading = false
if (response.success) {
this.markers = this.markers.filter((marker) => marker.id !== id)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
@@ -0,0 +1,80 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
describe('phone passcode store', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('stores the server security state after setting a six digit passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: true, length: 6, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
const response = await phone.setPasscode('123456')
expect(response.success).toBe(true)
expect(phone.security).toEqual({
enabled: true,
length: 6,
lockedUntil: 0,
})
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
passcode: '123456',
})
})
it('keeps the configured state after a rejected unlock attempt', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_passcode',
success: false,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.unlockWithPasscode('9999')
expect(phone.security).toEqual({
enabled: true,
length: 4,
lockedUntil: 0,
})
})
it('clears the security state after disabling the passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: false, length: null, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.disablePasscode('1234')
expect(phone.security.enabled).toBe(false)
expect(phone.security.length).toBeNull()
})
})
+302 -1
View File
@@ -1,10 +1,15 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import type {
DeviceBootstrap,
DeviceSecurity,
PhoneDevice,
} from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
@@ -15,12 +20,19 @@ import {
type LocaleTree = Record<string, unknown>
export type PasscodeResponseData = {
attemptsRemaining?: number
retryAfter?: number
security?: DeviceSecurity
}
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
security?: DeviceSecurity
token?: string
}
@@ -179,6 +191,151 @@ const defaultLocales: LocaleTree = {
default: 'Flare could not complete the request.',
},
},
fliptok: {
name: 'FlipTok',
loading: 'Loading FlipTok',
following: 'Following',
forYou: 'For You',
verified: 'Verified account',
originalSound: 'original sound',
save: 'Save',
home: 'Home',
discover: 'Discover',
create: 'Create',
activity: 'Activity',
profile: 'Profile',
emptyFeed: 'No videos yet',
emptyFeedBody: 'Follow creators or post the first FlipTok.',
searchPlaceholder: 'Search creators and videos',
noActivity: 'No activity yet',
followers: 'Followers',
videos: 'Videos',
emptyBio: 'No bio yet.',
editProfile: 'Edit profile',
newVideo: 'New FlipTok',
chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery',
changeVideo: 'Change',
captionPlaceholder: 'Write a caption...',
location: 'Add location',
whoCanWatch: 'Who can watch',
public: 'Everyone',
followersOnly: 'Followers',
private: 'Only me',
allowComments: 'Allow comments',
saveDraft: 'Drafts',
publishing: 'Posting...',
post: 'Post',
draftSaved: 'Draft saved.',
published: 'Your FlipTok is live.',
linkCopied: 'Video link copied.',
reported: 'Report submitted.',
blocked: 'Creator blocked.',
comments: 'Comments',
noComments: 'No comments yet',
addComment: 'Add comment...',
report: 'Report video',
reportReason: 'Reason',
reportDetails: 'Additional details (optional)',
submitReport: 'Submit report',
reportReasons: {
spam: 'Spam or misleading',
harassment: 'Harassment or bullying',
dangerous: 'Dangerous activity',
illegal: 'Illegal content',
other: 'Something else',
},
block: 'Block creator',
follow: 'Follow',
unfollow: 'Following',
backToProfile: 'Back',
sounds: 'Sound',
chooseSound: 'Choose music',
originalOnly: 'Original sound only',
noMusic: 'No music tracks are configured.',
trimAndCover: 'Trim & cover',
trimStart: 'Start',
trimEnd: 'End',
coverFrame: 'Cover',
originalVolume: 'Original sound',
musicVolume: 'Music',
moderation: 'Moderation',
reports: 'Open reports',
noReports: 'No open reports',
removeVideo: 'Remove video',
dismissReport: 'Dismiss',
cancel: 'Cancel',
done: 'Done',
displayName: 'Name',
username: 'Username',
bio: 'Bio',
accountType: 'Account type',
authTitle: 'Your FlipTok account',
login: 'Sign In',
register: 'Register',
createAccount: 'Create Account',
logout: 'Sign Out',
loginBody:
'Sign in to continue with your videos, follows, and saved posts.',
registerBody: 'Create a private FlipTok login for this profile.',
password: 'Password',
confirmPassword: 'Confirm password',
passwordsMismatch: 'The passwords do not match.',
displayNamePlaceholder: 'Your name',
usernamePlaceholder: 'username',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordPlaceholder: 'Enter password again',
registrationHint:
'An iFruit account is required once to create and own a FlipTok profile.',
accountDetails: 'Account details',
profileDetails: 'Profile details',
account: 'Account',
signOutTitle: 'Sign out of FlipTok?',
signOutBody:
'Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.',
signingOut: 'Signing Out...',
accountTypes: {
person: 'Person',
business: 'Business',
organization: 'Organization',
media: 'Media',
event: 'Event',
},
activityKinds: {
like: 'liked your video',
comment: 'commented on your video',
follow: 'started following you',
verified: 'verification changed',
},
notifications: {
like: '{actor} liked your video.',
comment: '{actor} commented on your video.',
follow: '{actor} started following you.',
verified: 'Your FlipTok account is now verified.',
default: 'You have new FlipTok activity.',
},
errors: {
invalid_video: 'Check the video details.',
invalid_media: 'Choose a video from this phone.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
invalid_profile: 'Check your profile details.',
invalid_handle: 'Use 324 letters, numbers, dots, or underscores.',
invalid_display_name: 'Enter a display name.',
invalid_password: 'Password must be 872 characters.',
invalid_credentials: 'Username or password is incorrect.',
already_registered:
'This iFruit account already owns a registered FlipTok profile.',
handle_taken: 'This username is already taken.',
video_not_found: 'This video is unavailable.',
rate_limited: 'Too many actions. Try again shortly.',
not_authenticated: 'Sign in to iFruit first.',
blocked: 'This account is blocked.',
not_authorized: 'You do not have moderation access.',
report_not_found: 'This report is no longer open.',
default: 'FlipTok could not complete the request.',
},
},
darkchat: {
name: 'DarkChat',
newMessage: 'New DarkChat message from {sender}',
@@ -1098,6 +1255,36 @@ const defaultLocales: LocaleTree = {
currentLocation: 'Current Location',
imageError: 'The map image could not be loaded.',
switchStyle: 'Switch Map Type',
addMarker: 'Add Marker',
placeMarker: 'Place Marker',
placeMarkerHint:
'Move the map until the crosshair is over the destination.',
addHere: 'Add Here',
newMarker: 'New Marker',
newMarkerDescription: 'Give this saved place a name and color.',
markerName: 'Name',
markerNamePlaceholder: 'e.g. Meeting point',
markerColor: 'Marker Color',
saveMarker: 'Save Marker',
deleteMarker: 'Delete Marker',
setWaypoint: 'Set Waypoint',
waypointSet: 'Waypoint set.',
markerSaved: 'Marker saved.',
markerDeleted: 'Marker deleted.',
colors: {
blue: 'Blue',
green: 'Green',
orange: 'Orange',
purple: 'Purple',
red: 'Red',
},
errors: {
invalid_marker: 'Enter a valid marker name and position.',
marker_limit: 'This phone has reached its marker limit.',
marker_not_found: 'This marker no longer exists.',
rate_limited: 'Too many changes. Try again shortly.',
request_failed: 'The marker could not be saved.',
},
styles: {
default: 'Default Map',
satellite: 'Satellite Map',
@@ -1153,6 +1340,8 @@ const defaultLocales: LocaleTree = {
portrait: 'Switch to portrait',
photo: 'Photo',
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
@@ -1170,6 +1359,8 @@ const defaultLocales: LocaleTree = {
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.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
@@ -1473,7 +1664,19 @@ const defaultLocales: LocaleTree = {
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
general: 'General Settings',
security: 'Passcode & Security',
appearance: 'Appearance',
connectivity: 'Connectivity',
connections: 'Connections',
wifi: 'Wi-Fi',
bluetooth: 'Bluetooth',
cellular: 'Cellular',
connectivityDescription:
'Airplane Mode temporarily disables wireless connections. Wi-Fi, Bluetooth, and cellular settings are saved on this phone.',
focus: 'Focus',
focusMode: 'Focus',
focusDescription:
'Focus silences non-critical notifications while keeping alarms and important alerts available.',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
notificationDuration: 'Notification Duration',
@@ -1488,6 +1691,8 @@ const defaultLocales: LocaleTree = {
dark: 'Dark',
phoneScale: 'Phone Scale',
phoneFrame: 'Phone Frame',
screenBrightness: 'Screen Brightness',
rotationLock: 'Rotation Lock',
about: 'About',
deviceName: 'Device Name',
deviceNameValue: 'Sky Phone',
@@ -1517,6 +1722,28 @@ const defaultLocales: LocaleTree = {
'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.',
passcode: {
description:
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
status: 'Passcode',
codeLength: 'Code Length',
sixDigit: '6-Digit Code',
fourDigit: '4-Digit Code',
turnOn: 'Turn Passcode On',
turnOff: 'Turn Passcode Off',
change: 'Change Passcode',
enterNew: 'Enter New Passcode',
confirmNew: 'Verify New Passcode',
enterCurrent: 'Enter Current Passcode',
screenSubtitle: 'Use 4 or 6 numbers.',
incorrect: 'Incorrect passcode.',
mismatch: 'The passcodes did not match.',
locked: 'Too many incorrect attempts. Try again later.',
rateLimited: 'Too many attempts. Please wait.',
failed: 'The passcode could not be updated.',
saved: 'Passcode saved.',
disabled: 'Passcode turned off.',
},
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
@@ -1532,6 +1759,11 @@ const defaultLocales: LocaleTree = {
toggle: {
airplaneMode: 'Toggle Airplane Mode',
streamerMode: 'Toggle Streamer Mode',
focusMode: 'Toggle Focus',
wifiEnabled: 'Toggle Wi-Fi',
bluetoothEnabled: 'Toggle Bluetooth',
cellularEnabled: 'Toggle Cellular Data',
rotationLocked: 'Toggle Rotation Lock',
notifications: 'Toggle notifications for {app}',
notificationSounds: 'Toggle notification sounds for {app}',
},
@@ -1604,6 +1836,7 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
use: 'Use',
},
Notifications: { now: 'now' },
LockScreen: {
@@ -1611,6 +1844,16 @@ const defaultLocales: LocaleTree = {
flashlight: 'Flashlight',
camera: 'Camera',
swipeUp: 'Swipe up to open',
passcode: {
enter: 'Enter Passcode',
unlockSubtitle: 'Enter the passcode for this phone.',
cancel: 'Cancel',
delete: 'Delete digit',
incorrect: 'Incorrect passcode',
locked: 'Too many attempts. Try again in {seconds} seconds.',
tryAgain: 'Try again in {seconds} seconds',
rateLimited: 'Too many attempts. Please wait.',
},
},
Home: {
appLibrary: 'App Library',
@@ -1726,6 +1969,11 @@ export const usePhoneStore = defineStore('phone', {
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
security: {
enabled: false,
length: null,
lockedUntil: 0,
} as DeviceSecurity,
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
@@ -1744,6 +1992,11 @@ export const usePhoneStore = defineStore('phone', {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.security = payload.security ?? {
enabled: false,
length: null,
lockedUntil: 0,
}
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
@@ -1813,6 +2066,54 @@ export const usePhoneStore = defineStore('phone', {
this.preferences.settings.wallpaper = wallpaper
this.saveDeviceNamespace('settings', this.preferences)
},
async unlockWithPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>('security:unlock', {
passcode,
})
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async setPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:set-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async changePasscode(
currentPasscode: string,
newPasscode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:change-passcode',
{ currentPasscode, newPasscode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async disablePasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:disable-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)