mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
MERGE - resolve dev integration conflicts
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 3–24 letters, numbers, dots, or underscores.',
|
||||
invalid_display_name: 'Enter a display name.',
|
||||
invalid_password: 'Password must be 8–72 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 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 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)
|
||||
|
||||
Reference in New Issue
Block a user