From a8a407689602e11d7682593c9d7b6873b76dc208 Mon Sep 17 00:00:00 2001 From: Eichenholz Date: Tue, 4 Aug 2026 18:36:04 +0200 Subject: [PATCH] ADD - connect optional iFruit account --- frontend/src/App.vue | 34 ++- frontend/src/stores/account.test.ts | 73 +++++++ frontend/src/stores/account.ts | 56 +++++ frontend/src/stores/clock.ts | 12 +- frontend/src/stores/mail.test.ts | 22 +- frontend/src/stores/mail.ts | 72 ++++--- frontend/src/stores/media.ts | 24 ++- frontend/src/stores/notes.ts | 33 +-- frontend/src/stores/phone.ts | 81 ++++++- frontend/src/types/device.ts | 33 +++ frontend/src/utils/alarms.ts | 22 +- frontend/src/utils/notes.test.ts | 2 +- frontend/src/utils/notes.ts | 18 +- frontend/src/utils/preferences.ts | 15 -- frontend/src/views/apps/MailApp.vue | 4 +- frontend/src/views/apps/SettingsApp.vue | 269 ++++++++++++++++++++++-- frontend/testserver/index.cjs | 90 +++++++- sky_phone/source/html/index.html | 2 +- 18 files changed, 733 insertions(+), 129 deletions(-) create mode 100644 frontend/src/stores/account.test.ts create mode 100644 frontend/src/stores/account.ts create mode 100644 frontend/src/types/device.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f65b2f2..e7d9b75 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,10 @@ import PhoneNotifications from '@/components/PhoneNotifications.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { useClockStore } from '@/stores/clock' +import { useAccountStore } from '@/stores/account' +import { useMailStore } from '@/stores/mail' +import { useMediaStore } from '@/stores/media' +import { useNotesStore } from '@/stores/notes' import { useNotificationsStore, type PhoneNotificationInput, @@ -36,7 +40,11 @@ const PHONE_BASE_SCALE = 0.69 const isDevelopment = import.meta.env.DEV const phone = usePhoneStore() +const account = useAccountStore() const clock = useClockStore() +const mail = useMailStore() +const media = useMediaStore() +const notes = useNotesStore() const notifications = useNotificationsStore() const route = useRoute() const router = useRouter() @@ -66,9 +74,20 @@ function getViewportScale(): number { return Math.min(window.innerWidth / REFERENCE_VIEWPORT_WIDTH, heightScale) } +function hydratePhone(payload: PhoneOpenPayload): void { + phone.open(payload) + account.hydrate(payload.account ?? null) + notes.hydrate(payload.notes ?? []) + clock.hydrate(payload.device?.data.alarms?.payload) + media.hydrate(payload.device?.data.media?.payload) + void mail.bootstrap(payload.account?.email ?? '') +} + function onMessage(event: MessageEvent): void { if (event.data?.type === 'app:open') { - phone.open(event.data.data as PhoneOpenPayload) + hydratePhone(event.data.data as PhoneOpenPayload) + } else if (event.data?.type === 'device:updated') { + hydratePhone(event.data.data as PhoneOpenPayload) } else if (event.data?.type === 'app:close') { phone.close() } else if (event.data?.type === 'notification:show' && event.data.data) { @@ -140,7 +159,18 @@ onMounted(() => { }) } }, 1000) - if (isDevelopment) phone.open() + if (isDevelopment) { + hydratePhone({ + account: null, + device: { + data: {}, + imei: '356938035643809', + name: 'iFruit Phone', + }, + notes: [], + token: 'development', + }) + } }) watch( diff --git a/frontend/src/stores/account.test.ts b/frontend/src/stores/account.test.ts new file mode 100644 index 0000000..ed32192 --- /dev/null +++ b/frontend/src/stores/account.test.ts @@ -0,0 +1,73 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useAccountStore } from '@/stores/account' +import type { AccountDevice } from '@/types/device' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) +const devices: AccountDevice[] = [ + { + created_at: '2026-08-04 10:00:00', + current: true, + device_name: 'iFruit Phone', + imei: '356938035643809', + updated_at: '2026-08-04 10:00:00', + }, +] + +describe('account store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('links the whole device after account login', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { devices, email: 'alex@ifruit.com' }, + success: true, + }) + + const account = useAccountStore() + const response = await account.login('alex', 'roleplay') + + expect(response.success).toBe(true) + expect(account.email).toBe('alex@ifruit.com') + expect(account.devices).toEqual(devices) + expect(mockNuiCall).toHaveBeenCalledWith('account:login', { + email: 'alex', + password: 'roleplay', + }) + }) + + it('keeps the existing account state when credentials fail', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'invalid_credentials', + success: false, + }) + + const account = useAccountStore() + account.hydrate({ devices, email: 'alex@ifruit.com' }) + await account.login('alex', 'wrong-password') + + expect(account.email).toBe('alex@ifruit.com') + expect(account.devices).toEqual(devices) + }) + + it('clears account state after a factory reset', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + + const account = useAccountStore() + account.hydrate({ devices, email: 'alex@ifruit.com' }) + const success = await account.factoryReset() + + expect(success).toBe(true) + expect(account.email).toBe('') + expect(account.devices).toEqual([]) + expect(mockNuiCall).toHaveBeenCalledWith('device:factory-reset') + }) +}) diff --git a/frontend/src/stores/account.ts b/frontend/src/stores/account.ts new file mode 100644 index 0000000..057f50e --- /dev/null +++ b/frontend/src/stores/account.ts @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' + +import type { AccountDevice, IfruitAccount } from '@/types/device' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useAccountStore = defineStore('account', { + state: () => ({ + devices: [] as AccountDevice[], + email: '', + }), + actions: { + hydrate(account: IfruitAccount | null): void { + this.email = account?.email ?? '' + this.devices = account?.devices ?? [] + }, + async login(email: string, password: string): Promise> { + const response = await nuiCall('account:login', { + email, + password, + }) + if (response.success && response.data) this.hydrate(response.data) + return response + }, + async register(email: string, password: string): Promise> { + const response = await nuiCall('account:register', { + email, + password, + }) + if (response.success && response.data) this.hydrate(response.data) + return response + }, + async logout(): Promise { + const response = await nuiCall('account:logout') + if (response.success) this.hydrate(null) + return response.success + }, + async loadDevices(): Promise { + const response = await nuiCall('account:devices') + if (response.success && response.data) this.devices = response.data + return response.success + }, + async removeDevice(imei: string, password: string): Promise> { + const response = await nuiCall('account:remove-device', { + imei, + password, + }) + if (response.success && response.data) this.devices = response.data + return response + }, + async factoryReset(): Promise { + const response = await nuiCall('device:factory-reset') + if (response.success) this.hydrate(null) + return response.success + }, + }, +}) diff --git a/frontend/src/stores/clock.ts b/frontend/src/stores/clock.ts index d6d6907..cd65a47 100644 --- a/frontend/src/stores/clock.ts +++ b/frontend/src/stores/clock.ts @@ -5,15 +5,16 @@ import { type Alarm, type AlarmDraft, type AlarmSoundId, + DEFAULT_ALARMS, isAlarmDue, - readAlarms, - writeAlarms, + parseAlarms, } from '@/utils/alarms' import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock' +import { usePhoneStore } from '@/stores/phone' export const useClockStore = defineStore('clock', { state: () => ({ - alarms: readAlarms(), + alarms: structuredClone(DEFAULT_ALARMS), laps: [] as number[], stopwatchAccumulated: 0, stopwatchStartedAt: null as number | null, @@ -81,7 +82,10 @@ export const useClockStore = defineStore('clock', { this.timerStartedAt = null }, persistAlarms(): void { - writeAlarms(this.alarms) + usePhoneStore().saveDeviceNamespace('alarms', this.alarms) + }, + hydrate(alarms: unknown): void { + this.alarms = parseAlarms(alarms) }, resetStopwatch(): void { this.stopwatchAccumulated = 0 diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts index f2c2295..bcf80cc 100644 --- a/frontend/src/stores/mail.test.ts +++ b/frontend/src/stores/mail.test.ts @@ -88,9 +88,10 @@ describe('mail store', () => { it('logs out the active session and resets mailbox state', async () => { mockNuiCall .mockResolvedValueOnce({ - data: { counts, email: 'alex@ifruit.com' }, + data: { devices: [], email: 'alex@ifruit.com' }, success: true, }) + .mockResolvedValueOnce({ data: counts, success: true }) .mockResolvedValueOnce({ data: { hasMore: true, items: [listItem(1)] }, success: true, @@ -109,4 +110,23 @@ describe('mail store', () => { expect(mail.items).toEqual([]) expect(mail.hasMore).toBe(false) }) + + it('removes loaded cloud mail when the device becomes unlinked', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: counts, success: true }) + .mockResolvedValueOnce({ + data: { hasMore: true, items: [listItem(1)] }, + success: true, + }) + + const mail = useMailStore() + await mail.bootstrap('alex@ifruit.com') + await mail.loadFolder('sent', 'plans') + await mail.bootstrap('') + + expect(mail.accountEmail).toBe('') + expect(mail.items).toEqual([]) + expect(mail.folder).toBe('inbox') + expect(mail.search).toBe('') + }) }) diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts index 14ea9cc..238bea6 100644 --- a/frontend/src/stores/mail.ts +++ b/frontend/src/stores/mail.ts @@ -1,6 +1,8 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { useAccountStore } from '@/stores/account' +import type { IfruitAccount } from '@/types/device' import type { MailComposeDraft, MailCounts, @@ -9,7 +11,6 @@ import type { MailListItem, MailListResponse, MailMessage, - MailSession, } from '@/types/mail' import { nuiCall } from '@/utils/nui' @@ -22,6 +23,7 @@ const emptyCounts = (): MailCounts => ({ }) export const useMailStore = defineStore('mail', () => { + const account = useAccountStore() const accountEmail = ref('') const counts = ref(emptyCounts()) const folder = ref('inbox') @@ -30,31 +32,7 @@ export const useMailStore = defineStore('mail', () => { const loading = ref(false) const search = ref('') - function applySession(session: MailSession): void { - accountEmail.value = session.email - counts.value = session.counts - } - - async function login(email: string, password: string) { - const response = await nuiCall('mail:login', { - email, - password, - }) - if (response.success && response.data) applySession(response.data) - return response - } - - async function register(email: string, password: string) { - const response = await nuiCall('mail:register', { - email, - password, - }) - if (response.success && response.data) applySession(response.data) - return response - } - - async function logout(): Promise { - if (accountEmail.value) await nuiCall('mail:logout') + function clearSession(): void { accountEmail.value = '' counts.value = emptyCounts() items.value = [] @@ -63,6 +41,47 @@ export const useMailStore = defineStore('mail', () => { search.value = '' } + async function bootstrap(email: string): Promise { + if (!email) { + clearSession() + return + } + accountEmail.value = email + await refreshCounts() + } + + async function login(email: string, password: string) { + const response = await nuiCall('mail:login', { + email, + password, + }) + if (response.success && response.data) { + account.hydrate(response.data) + await bootstrap(response.data.email) + } + return response + } + + async function register(email: string, password: string) { + const response = await nuiCall('mail:register', { + email, + password, + }) + if (response.success && response.data) { + account.hydrate(response.data) + await bootstrap(response.data.email) + } + return response + } + + async function logout(): Promise { + if (accountEmail.value) { + const response = await nuiCall('mail:logout') + if (response.success) account.hydrate(null) + } + clearSession() + } + async function loadFolder( nextFolder: MailFolder, nextSearch = '', @@ -160,6 +179,7 @@ export const useMailStore = defineStore('mail', () => { return { accountEmail, + bootstrap, counts, deleteDraft, emptyTrash, diff --git a/frontend/src/stores/media.ts b/frontend/src/stores/media.ts index 5bedaf6..a3512d4 100644 --- a/frontend/src/stores/media.ts +++ b/frontend/src/stores/media.ts @@ -1,5 +1,7 @@ import { defineStore } from 'pinia' +import { usePhoneStore } from '@/stores/phone' + export type PhonePhoto = { capturedAt: number gradient: string @@ -59,9 +61,29 @@ export const useMediaStore = defineStore('media', { id: `capture-${Date.now()}`, titleKey: 'Apps.photos.samples.capture', }) + this.persist() }, claimApp(id: string): void { - if (!this.claimedApps.includes(id)) this.claimedApps.push(id) + if (!this.claimedApps.includes(id)) { + this.claimedApps.push(id) + this.persist() + } + }, + hydrate(payload: unknown): void { + const data = payload as Partial<{ + captures: PhonePhoto[] + claimedApps: string[] + }> | null + this.captures = Array.isArray(data?.captures) ? data.captures : [] + this.claimedApps = Array.isArray(data?.claimedApps) + ? data.claimedApps.filter((id): id is string => typeof id === 'string') + : [] + }, + persist(): void { + usePhoneStore().saveDeviceNamespace('media', { + captures: this.captures, + claimedApps: this.claimedApps, + }) }, }, }) diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index a841cd3..9c2e83a 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -1,15 +1,11 @@ import { defineStore } from 'pinia' -import { - type Note, - type NoteDraft, - readNotes, - writeNotes, -} from '@/utils/notes' +import { nuiCall } from '@/utils/nui' +import { type Note, type NoteDraft } from '@/utils/notes' export const useNotesStore = defineStore('notes', { state: () => ({ - notes: readNotes(), + notes: [] as Note[], }), actions: { createNote(draft: NoteDraft): Note { @@ -19,30 +15,41 @@ export const useNotesStore = defineStore('notes', { createdAt: now, id: `note-${now}-${Math.random().toString(36).slice(2, 9)}`, pinned: false, + revision: 1, updatedAt: now, } this.notes.unshift(note) - this.persist() + void this.createRemote(note) return note }, deleteNote(id: string): void { this.notes = this.notes.filter((note) => note.id !== id) - this.persist() + void nuiCall('notes:delete', { id }).then((response) => { + if (response.success && response.data) this.hydrate(response.data) + }) }, - persist(): void { - writeNotes(this.notes) + hydrate(notes: Note[]): void { + this.notes = structuredClone(notes) + }, + async createRemote(note: Note): Promise { + const response = await nuiCall('notes:create', note) + if (response.data) this.hydrate(response.data) }, togglePinned(id: string): void { const note = this.notes.find((candidate) => candidate.id === id) if (!note) return note.pinned = !note.pinned - this.persist() + void this.updateRemote(note) }, updateNote(id: string, draft: NoteDraft): void { const note = this.notes.find((candidate) => candidate.id === id) if (!note) return Object.assign(note, draft, { updatedAt: Date.now() }) - this.persist() + void this.updateRemote(note) + }, + async updateRemote(note: Note): Promise { + const response = await nuiCall('notes:update', note) + if (response.data) this.hydrate(response.data) }, }, }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 83ad82c..2772514 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1,22 +1,30 @@ import { defineStore } from 'pinia' import type { AppLaunchOrigin, PhoneAppId } from '@/types/apps' +import type { DeviceBootstrap, PhoneDevice } from '@/types/device' import { clampPage } from '@/utils/pages' +import { nuiCall } from '@/utils/nui' import { - readPhonePreferences, + DEFAULT_PHONE_PREFERENCES, + parsePhonePreferences, type AppNotificationPreferences, type PhonePreferencesV1, type WallpaperId, - writePhonePreferences, } from '@/utils/preferences' type LocaleTree = Record export type PhoneOpenPayload = { + account?: DeviceBootstrap['account'] + device?: PhoneDevice lang?: string locales?: LocaleTree + notes?: DeviceBootstrap['notes'] + token?: string } +const namespaceQueues = new Map>() + const defaultLocales: LocaleTree = { Apps: { appStore: { @@ -275,8 +283,11 @@ const defaultLocales: LocaleTree = { on: 'On', off: 'Off', accountName: 'iFruit Account', - accountDetail: 'Cloud, Media & Purchases', - accountLocalDetail: 'Local account for this phone', + accountDetail: 'Cloud, Mail & Notes', + accountLocalDetail: 'Not signed in', + accountCloudDetail: 'Mail and notes sync through iFruit Cloud', + accountLoginBody: + 'Sign in is optional. This phone also works with local data only.', accountInformation: 'Account Information', accountStatus: 'Account Status', accountStatusValue: 'Active', @@ -310,6 +321,27 @@ const defaultLocales: LocaleTree = { languageValue: 'English', localStorage: 'Local Storage', localStorageValue: 'On Device', + deviceInformation: 'Device Information', + imei: 'IMEI', + 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.', + accountErrors: { + invalid_email: 'Choose a valid 3–32 character iFruit address.', + invalid_password: 'Password must be 6–64 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: { @@ -409,11 +441,13 @@ function getByPath(source: LocaleTree, path: string): unknown { export const usePhoneStore = defineStore('phone', { state: () => ({ currentPage: 1, + device: null as PhoneDevice | null, + deviceRevisions: {} as Record, isOpen: false, lang: 'en', launchOrigin: null as AppLaunchOrigin | null, locales: defaultLocales, - preferences: readPhonePreferences(), + preferences: structuredClone(DEFAULT_PHONE_PREFERENCES), systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, }), getters: { @@ -430,8 +464,39 @@ export const usePhoneStore = defineStore('phone', { 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): void { this.currentPage = clampPage(page) }, @@ -444,21 +509,21 @@ export const usePhoneStore = defineStore('phone', { value: boolean, ): void { this.preferences.settings.notifications[appId][key] = value - writePhonePreferences(this.preferences) + this.saveDeviceNamespace('settings', this.preferences) }, setPreference( key: K, value: PhonePreferencesV1['settings'][K], ): void { this.preferences.settings[key] = value - writePhonePreferences(this.preferences) + this.saveDeviceNamespace('settings', this.preferences) }, setSystemDarkMode(value: boolean): void { this.systemDarkMode = value }, setWallpaper(wallpaper: WallpaperId): void { this.preferences.settings.wallpaper = wallpaper - writePhonePreferences(this.preferences) + this.saveDeviceNamespace('settings', this.preferences) }, t(path: string, replacements: Record = {}): string { const translated = getByPath(this.locales, path) diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts new file mode 100644 index 0000000..d9f8113 --- /dev/null +++ b/frontend/src/types/device.ts @@ -0,0 +1,33 @@ +import type { Note } from '@/utils/notes' + +export type DeviceDataEntry = { + payload: T + revision: number +} + +export type PhoneDevice = { + data: Record + imei: string + name: string +} + +export type AccountDevice = { + created_at: string + current: boolean + device_name: string + imei: string + updated_at: string +} + +export type IfruitAccount = { + devices: AccountDevice[] + email: string + id?: number +} + +export type DeviceBootstrap = { + account: IfruitAccount | null + device: PhoneDevice + notes: Note[] + token: string +} diff --git a/frontend/src/utils/alarms.ts b/frontend/src/utils/alarms.ts index dd7b4d4..e5f1199 100644 --- a/frontend/src/utils/alarms.ts +++ b/frontend/src/utils/alarms.ts @@ -1,5 +1,3 @@ -export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1' - export const ALARM_SOUND_IDS = [ 'radar', 'beacon', @@ -27,7 +25,7 @@ export type Alarm = { export type AlarmDraft = Pick -const DEFAULT_ALARMS: Alarm[] = [ +export const DEFAULT_ALARMS: Alarm[] = [ { enabled: true, id: 'weekday', @@ -90,21 +88,9 @@ function readAlarm(value: unknown): Alarm | null { } } -export function readAlarms(): Alarm[] { - const raw = window.localStorage.getItem(ALARMS_STORAGE_KEY) - if (!raw) return structuredClone(DEFAULT_ALARMS) - - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) return structuredClone(DEFAULT_ALARMS) - return parsed.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm) - } catch { - return structuredClone(DEFAULT_ALARMS) - } -} - -export function writeAlarms(alarms: Alarm[]): void { - window.localStorage.setItem(ALARMS_STORAGE_KEY, JSON.stringify(alarms)) +export function parseAlarms(value: unknown): Alarm[] { + if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS) + return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm) } export function alarmMinuteKey(date: Date): string { diff --git a/frontend/src/utils/notes.test.ts b/frontend/src/utils/notes.test.ts index c6fb610..340d5d4 100644 --- a/frontend/src/utils/notes.test.ts +++ b/frontend/src/utils/notes.test.ts @@ -14,7 +14,7 @@ describe('notes persistence', () => { } expect(parseNotes(JSON.stringify([valid, { id: 'broken' }]))).toEqual([ - valid, + { ...valid, revision: 1 }, ]) }) diff --git a/frontend/src/utils/notes.ts b/frontend/src/utils/notes.ts index e748481..e01210a 100644 --- a/frontend/src/utils/notes.ts +++ b/frontend/src/utils/notes.ts @@ -1,10 +1,9 @@ -export const NOTES_STORAGE_KEY = 'sky_phone.notes.v1' - export type Note = { body: string createdAt: number id: string pinned: boolean + revision: number title: string updatedAt: number } @@ -21,6 +20,8 @@ function isNote(value: unknown): value is Note { typeof note.id === 'string' && Boolean(note.id) && typeof note.pinned === 'boolean' && + (note.revision === undefined || + (typeof note.revision === 'number' && Number.isFinite(note.revision))) && typeof note.title === 'string' && typeof note.updatedAt === 'number' && Number.isFinite(note.updatedAt) @@ -33,16 +34,11 @@ export function parseNotes(raw: string | null): Note[] { try { const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return [] - return parsed.filter(isNote) + return parsed.filter(isNote).map((note) => ({ + ...note, + revision: note.revision ?? 1, + })) } catch { return [] } } - -export function readNotes(): Note[] { - return parseNotes(window.localStorage.getItem(NOTES_STORAGE_KEY)) -} - -export function writeNotes(notes: Note[]): void { - window.localStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(notes)) -} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 54f7755..bc0cf68 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -1,7 +1,5 @@ import type { PhoneAppId } from '@/types/apps' -export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1' - export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const export const PHONE_FRAME_IDS = [ 'black', @@ -197,16 +195,3 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 { return structuredClone(DEFAULT_PHONE_PREFERENCES) } } - -export function readPhonePreferences(): PhonePreferencesV1 { - return parsePhonePreferences( - window.localStorage.getItem(PHONE_PREFERENCES_KEY), - ) -} - -export function writePhonePreferences(preferences: PhonePreferencesV1): void { - window.localStorage.setItem( - PHONE_PREFERENCES_KEY, - JSON.stringify(preferences), - ) -} diff --git a/frontend/src/views/apps/MailApp.vue b/frontend/src/views/apps/MailApp.vue index cbaa8f7..0ba6944 100644 --- a/frontend/src/views/apps/MailApp.vue +++ b/frontend/src/views/apps/MailApp.vue @@ -361,9 +361,7 @@ onBeforeUnmount(() => { if (draftTimer) clearTimeout(draftTimer) if (searchTimer) clearTimeout(searchTimer) if (screen.value === 'compose') { - void saveDraftNow().finally(() => mail.logout()) - } else { - void mail.logout() + void saveDraftNow() } }) diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index bb46ba7..737c6dd 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -1,13 +1,20 @@ + + + + + + + + + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 517ea98..5f0deda 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -9,6 +9,18 @@ app.use(express.json()) let authenticated = false let draft = null +let linkedAccount = null +let mockNotes = [] +const deviceData = {} +const accountDevices = [ + { + created_at: '2026-08-04 12:00:00', + current: true, + device_name: 'iFruit Phone', + imei: '356938035643809', + updated_at: '2026-08-04 12:00:00', + }, +] const messages = [ { body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.', @@ -82,12 +94,82 @@ function counts() { app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint + if (endpoint === 'account:login' || endpoint === 'account:register') { + authenticated = true + linkedAccount = { + devices: accountDevices, + email: request.body.email.includes('@') + ? request.body.email + : `${request.body.email}@ifruit.com`, + } + response.json({ success: true, data: linkedAccount }) + return + } + if (endpoint === 'account:logout') { + authenticated = false + linkedAccount = null + response.json({ success: true }) + return + } + if (endpoint === 'account:devices') { + response.json({ success: true, data: accountDevices }) + return + } + if (endpoint === 'account:remove-device') { + response.json({ success: true, data: accountDevices }) + return + } + if (endpoint === 'device:save') { + const current = deviceData[request.body.namespace] + const revision = (current?.revision ?? 0) + 1 + deviceData[request.body.namespace] = { + payload: request.body.payload, + revision, + } + response.json({ success: true, data: { revision } }) + return + } + if (endpoint === 'device:factory-reset') { + authenticated = false + linkedAccount = null + mockNotes = [] + for (const key of Object.keys(deviceData)) delete deviceData[key] + response.json({ success: true }) + return + } + if (endpoint === 'notes:list') { + response.json({ success: true, data: mockNotes }) + return + } + if (endpoint === 'notes:create') { + mockNotes.unshift({ ...request.body, revision: 1 }) + response.json({ success: true, data: mockNotes }) + return + } + if (endpoint === 'notes:update') { + const index = mockNotes.findIndex((note) => note.id === request.body.id) + if (index >= 0) { + mockNotes[index] = { + ...request.body, + revision: mockNotes[index].revision + 1, + updatedAt: Date.now(), + } + } + response.json({ success: true, data: mockNotes }) + return + } + if (endpoint === 'notes:delete') { + mockNotes = mockNotes.filter((note) => note.id !== request.body.id) + response.json({ success: true, data: mockNotes }) + return + } if (endpoint === 'mail:login' || endpoint === 'mail:register') { authenticated = true - response.json({ - success: true, - data: { counts: counts(), email: 'demo@ifruit.com' }, - }) + linkedAccount = { + devices: accountDevices, + email: 'demo@ifruit.com', + } + response.json({ success: true, data: linkedAccount }) return } if (endpoint === 'mail:logout') { diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 9f8d07a..9f60ce1 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,7 +4,7 @@ Sky Phone - +