ENH - update SMS branch from dev

This commit is contained in:
Eichenholz
2026-08-06 19:42:54 +02:00
219 changed files with 29676 additions and 1309 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
import { cloneJsonData } from '@/utils/clone'
export const ALARM_SOUND_IDS = [
'radar',
'beacon',
@@ -89,7 +91,7 @@ function readAlarm(value: unknown): Alarm | null {
}
export function parseAlarms(value: unknown): Alarm[] {
if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS)
if (!Array.isArray(value)) return cloneJsonData(DEFAULT_ALARMS)
return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
}
+5
View File
@@ -0,0 +1,5 @@
export function cloneJsonData<T>(value: T): T {
const serialized = JSON.stringify(value)
if (serialized === undefined) return value
return JSON.parse(serialized) as T
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { gameViewGeometry } from '@/utils/gameView'
describe('gameViewGeometry', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
const geometry = gameViewGeometry(1920, 1080, 540, 720)
expect(Array.from(geometry.textureCoordinates)).toEqual([
expect.closeTo(0.29, 2),
0,
expect.closeTo(0.71, 2),
0,
expect.closeTo(0.29, 2),
1,
expect.closeTo(0.71, 2),
1,
])
})
it('keeps the full game view for 16:9 landscape output', () => {
const geometry = gameViewGeometry(1920, 1080, 720, 405)
expect(Array.from(geometry.textureCoordinates)).toEqual([
0, 0, 1, 0, 0, 1, 1, 1,
])
})
it('keeps the full texture when both aspect ratios match', () => {
const geometry = gameViewGeometry(1600, 900, 800, 450)
expect(Array.from(geometry.textureCoordinates)).toEqual([
0, 0, 1, 0, 0, 1, 1, 1,
])
})
it('keeps 0.5x full-frame while higher zoom levels crop around the center', () => {
const wideGeometry = gameViewGeometry(1920, 1080, 540, 720, 0.5)
expect(Array.from(wideGeometry.textureCoordinates)).toEqual([
expect.closeTo(0.29, 2),
0,
expect.closeTo(0.71, 2),
0,
expect.closeTo(0.29, 2),
1,
expect.closeTo(0.71, 2),
1,
])
expect(Array.from(wideGeometry.positions)).toEqual([
-1, -1, 1, -1, -1, 1, 1, 1,
])
const zoomedGeometry = gameViewGeometry(1920, 1080, 540, 720, 2)
expect(Array.from(zoomedGeometry.textureCoordinates)).toEqual([
expect.closeTo(0.39, 2),
0.25,
expect.closeTo(0.61, 2),
0.25,
expect.closeTo(0.39, 2),
0.75,
expect.closeTo(0.61, 2),
0.75,
])
})
})
+238
View File
@@ -0,0 +1,238 @@
const VERTEX_SHADER = `
attribute vec2 a_position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texcoord = a_texcoord;
}
`
const FRAGMENT_SHADER = `
varying highp vec2 v_texcoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord);
}
`
export interface GameView {
readonly canvas: HTMLCanvasElement
dispose(): void
isLost(): boolean
render(): void
resize(
width: number,
height: number,
sourceWidth?: number,
sourceHeight?: number,
zoom?: number,
): void
}
export interface GameViewOptions {
preserveDrawingBuffer?: boolean
}
export function gameViewGeometry(
sourceWidth: number,
sourceHeight: number,
targetWidth: number,
targetHeight: number,
zoom = 1,
): { positions: Float32Array; textureCoordinates: Float32Array } {
const sourceAspect = sourceWidth / sourceHeight
const targetAspect = targetWidth / targetHeight
let left = 0
let right = 1
let top = 0
let bottom = 1
if (sourceAspect > targetAspect) {
const visibleWidth = targetAspect / sourceAspect
left = (1 - visibleWidth) / 2
right = 1 - left
} else if (sourceAspect < targetAspect) {
const visibleHeight = sourceAspect / targetAspect
top = (1 - visibleHeight) / 2
bottom = 1 - top
}
const normalizedZoom = Math.min(3, Math.max(1, zoom))
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
left = Math.max(0, centerX + (left - centerX) / normalizedZoom)
right = Math.min(1, centerX + (right - centerX) / normalizedZoom)
top = Math.max(0, centerY + (top - centerY) / normalizedZoom)
bottom = Math.min(1, centerY + (bottom - centerY) / normalizedZoom)
return {
positions: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
textureCoordinates: new Float32Array([
left,
top,
right,
top,
left,
bottom,
right,
bottom,
]),
}
}
function compileShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader {
const shader = gl.createShader(type)
if (!shader) throw new Error('game_view_shader_unavailable')
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) || 'game_view_shader_failed')
}
return shader
}
export function createGameView(
canvas: HTMLCanvasElement,
options: GameViewOptions = {},
): GameView {
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
depth: false,
desynchronized: true,
failIfMajorPerformanceCaveat: false,
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
stencil: false,
}) as WebGLRenderingContext | null
if (!gl) throw new Error('game_view_unavailable')
let lost = false
let disposed = false
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
}
canvas.addEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
const program = gl.createProgram()
if (!program) throw new Error('game_view_program_unavailable')
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
gl.attachShader(
program,
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
}
gl.useProgram(program)
const positionLocation = gl.getAttribLocation(program, 'a_position')
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
throw new Error('game_view_attributes_unavailable')
}
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.DYNAMIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
const texcoordBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(texcoordLocation)
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
// game backbuffer. These calls are intentionally not redundant.
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
gl.clearColor(0, 0, 0, 1)
return {
canvas,
dispose() {
if (disposed) return
disposed = true
canvas.removeEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
if (disposed || lost) return
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
},
resize(
width: number,
height: number,
sourceWidth = window.innerWidth,
sourceHeight = window.innerHeight,
zoom = 1,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
const geometry = gameViewGeometry(
sourceWidth,
sourceHeight,
width,
height,
zoom,
)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
geometry.textureCoordinates,
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, width, height)
},
}
}
+33 -1
View File
@@ -4,6 +4,9 @@ import type { MailMessage } from '@/types/mail'
import {
buildForwardDraft,
buildReplyDraft,
filterMailAddressInput,
filterMailRecipientInput,
mailPlainText,
normalizeMailAddress,
parseMailRecipients,
} from '@/utils/mail'
@@ -22,6 +25,23 @@ const message: MailMessage = {
}
describe('mail addresses', () => {
it('filters account fields to the supported email character set', () => {
expect(filterMailAddressInput('Al+ex! ä@ifruit.com<script>')).toBe(
'Alex@ifruit.comscript',
)
expect(filterMailAddressInput('sky.user_name-2@ifruit.com')).toBe(
'sky.user_name-2@ifruit.com',
)
})
it('keeps separators but filters recipient-list special characters', () => {
expect(
filterMailRecipientInput(
'alex@ifruit.com, jamie+tag@ifruit.com; müller@ifruit.com',
),
).toBe('alex@ifruit.com, jamietag@ifruit.com; mller@ifruit.com')
})
it('normalizes local parts and the iFruit domain', () => {
expect(normalizeMailAddress(' Sky.User ')).toBe('sky.user@ifruit.com')
expect(normalizeMailAddress('sky.user@ifruit.com')).toBe(
@@ -48,12 +68,24 @@ describe('mail compose helpers', () => {
expect(
buildReplyDraft(message, 'alex@ifruit.com', true).recipients,
).toEqual(['morgan@ifruit.com', 'jamie@ifruit.com'])
expect(buildReplyDraft(message, 'alex@ifruit.com').body).toContain(
'> Meet at Legion Square.',
)
})
it('builds forward content and avoids duplicate subject prefixes', () => {
const forwarded = buildForwardDraft(message)
expect(forwarded.recipients).toEqual([])
expect(forwarded.subject).toBe('Fwd: Plans')
expect(forwarded.body).toContain('From: morgan@ifruit.com')
expect(forwarded.body).toContain('**From:** morgan@ifruit.com')
})
it('turns formatted markdown into a compact mailbox preview', () => {
expect(
mailPlainText('## Update\n\n**Ready** [details](https://ifruit.com).'),
).toBe('Update Ready details.')
expect(mailPlainText('<script>alert(1)</script> Hello')).toBe(
'alert(1) Hello',
)
})
})
+36 -2
View File
@@ -2,6 +2,21 @@ import type { MailComposeDraft, MailMessage } from '@/types/mail'
export const MAIL_DOMAIN = 'ifruit.com'
export const MAIL_MAX_RECIPIENTS = 10
export const MAIL_ADDRESS_INPUT_MAX_LENGTH = 64
export const MAIL_RECIPIENT_INPUT_MAX_LENGTH =
MAIL_MAX_RECIPIENTS * (MAIL_ADDRESS_INPUT_MAX_LENGTH + 2)
export function filterMailAddressInput(value: string): string {
return value
.replace(/[^a-z0-9@._-]/gi, '')
.slice(0, MAIL_ADDRESS_INPUT_MAX_LENGTH)
}
export function filterMailRecipientInput(value: string): string {
return value
.replace(/[^a-z0-9@._,; -]/gi, '')
.slice(0, MAIL_RECIPIENT_INPUT_MAX_LENGTH)
}
export function normalizeMailAddress(value: string): string | null {
const normalized = value.trim().toLocaleLowerCase('en-US')
@@ -43,6 +58,25 @@ export function parseMailRecipients(value: string): string[] | null {
return recipients
}
export function mailPlainText(value: string): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+\.)\s+/gm, '')
.replace(/[*_~`]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
function quoteMarkdown(value: string): string {
return value
.trim()
.split('\n')
.map((line) => `> ${line}`)
.join('\n')
}
function prefixedSubject(prefix: 'Re' | 'Fwd', subject: string): string {
const clean = subject.trim()
if (new RegExp(`^${prefix}:`, 'i').test(clean)) return clean
@@ -62,7 +96,7 @@ export function buildReplyDraft(
)
return {
body: `\n--- ${message.sender} ---\n${message.body}`,
body: `\n\n> **${message.sender}**\n>\n${quoteMarkdown(message.body)}`,
recipients,
subject: prefixedSubject('Re', message.subject),
}
@@ -70,7 +104,7 @@ export function buildReplyDraft(
export function buildForwardDraft(message: MailMessage): MailComposeDraft {
return {
body: `\n--- Forwarded message ---\nFrom: ${message.sender}\nTo: ${message.recipients.join(', ')}\n\n${message.body}`,
body: `\n\n---\n\n**Forwarded message**\n\n**From:** ${message.sender}\n\n**To:** ${message.recipients.join(', ')}\n\n${message.body}`,
recipients: [],
subject: prefixedSubject('Fwd', message.subject),
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
clampMailSwipeOffset,
MAIL_SWIPE_ACTION_WIDTH,
resolveMailSwipeAction,
resolveMailSwipeAxis,
} from '@/utils/mailSwipe'
describe('mail swipe gestures', () => {
it('waits for a clear horizontal or vertical direction', () => {
expect(resolveMailSwipeAxis(5, 2)).toBeNull()
expect(resolveMailSwipeAxis(24, 5)).toBe('horizontal')
expect(resolveMailSwipeAxis(10, 18)).toBe('vertical')
})
it('limits horizontal movement and blocks unavailable directions', () => {
expect(clampMailSwipeOffset(70, true, true)).toBe(70)
expect(clampMailSwipeOffset(70, false, true)).toBe(0)
expect(clampMailSwipeOffset(-70, true, false)).toBe(0)
expect(clampMailSwipeOffset(-240, true, true)).toBeGreaterThan(
-MAIL_SWIPE_ACTION_WIDTH - 19,
)
})
it('only commits an action after crossing its threshold', () => {
expect(resolveMailSwipeAction(63, true, true)).toBeNull()
expect(resolveMailSwipeAction(64, true, true)).toBe('read')
expect(resolveMailSwipeAction(-64, true, true)).toBe('delete')
expect(resolveMailSwipeAction(80, false, true)).toBeNull()
})
})
+48
View File
@@ -0,0 +1,48 @@
export type MailSwipeAction = 'delete' | 'read'
export type MailSwipeAxis = 'horizontal' | 'vertical'
export const MAIL_SWIPE_ACTION_WIDTH = 84
export const MAIL_SWIPE_AXIS_LOCK = 8
export const MAIL_SWIPE_TRIGGER = 64
export function resolveMailSwipeAxis(
deltaX: number,
deltaY: number,
): MailSwipeAxis | null {
const horizontalDistance = Math.abs(deltaX)
const verticalDistance = Math.abs(deltaY)
if (Math.max(horizontalDistance, verticalDistance) < MAIL_SWIPE_AXIS_LOCK) {
return null
}
return horizontalDistance > verticalDistance * 1.15
? 'horizontal'
: 'vertical'
}
export function clampMailSwipeOffset(
deltaX: number,
canRead: boolean,
canDelete: boolean,
): number {
if ((deltaX > 0 && !canRead) || (deltaX < 0 && !canDelete)) return 0
const direction = Math.sign(deltaX)
const distance = Math.abs(deltaX)
const resistedDistance =
distance <= MAIL_SWIPE_ACTION_WIDTH
? distance
: MAIL_SWIPE_ACTION_WIDTH + (distance - MAIL_SWIPE_ACTION_WIDTH) * 0.18
return direction * Math.min(resistedDistance, MAIL_SWIPE_ACTION_WIDTH + 18)
}
export function resolveMailSwipeAction(
offset: number,
canRead: boolean,
canDelete: boolean,
): MailSwipeAction | null {
if (canRead && offset >= MAIL_SWIPE_TRIGGER) return 'read'
if (canDelete && offset <= -MAIL_SWIPE_TRIGGER) return 'delete'
return null
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
filterMedia,
formatRecordingDuration,
hasNextMediaPage,
mediaErrorKey,
mergeMedia,
} from './media'
const media = [
{ createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' },
]
describe('media utilities', () => {
it('filters gallery media by explicit type', () => {
expect(filterMedia(media, 'all')).toHaveLength(2)
expect(filterMedia(media, 'photo').map((entry) => entry.id)).toEqual([1])
expect(filterMedia(media, 'video').map((entry) => entry.id)).toEqual([2])
})
it('merges pages without duplicates and keeps newest first', () => {
expect(
mergeMedia(media, [
{ createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, id: 3, mediaType: 'photo', url: 'new' },
]).map((entry) => [entry.id, entry.url]),
).toEqual([
[1, 'updated'],
[3, 'new'],
[2, 'video'],
])
})
it('loads another gallery page only after a full 30-item batch', () => {
expect(hasNextMediaPage(30)).toBe(true)
expect(hasNextMediaPage(29)).toBe(false)
expect(hasNextMediaPage(0)).toBe(false)
})
it('formats unlimited recording durations', () => {
expect(formatRecordingDuration(0)).toBe('00:00')
expect(formatRecordingDuration(3_725_000)).toBe('62:05')
})
it('maps unknown server failures to the localized default', () => {
expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout')
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+59
View File
@@ -0,0 +1,59 @@
import type { GalleryFilter, MediaType, PhoneMedia } from '@/types/media'
export const MEDIA_PAGE_SIZE = 30
export function isMediaType(value: unknown): value is MediaType {
return value === 'photo' || value === 'video'
}
export function filterMedia(
media: PhoneMedia[],
filter: GalleryFilter,
): PhoneMedia[] {
return filter === 'all'
? media
: media.filter((entry) => entry.mediaType === filter)
}
export function mergeMedia(
current: PhoneMedia[],
incoming: PhoneMedia[],
): PhoneMedia[] {
const byId = new Map(current.map((entry) => [entry.id, entry]))
for (const entry of incoming) byId.set(entry.id, entry)
return [...byId.values()].sort(
(left, right) => right.createdAt - left.createdAt || right.id - left.id,
)
}
export function hasNextMediaPage(pageLength: number): boolean {
return pageLength === MEDIA_PAGE_SIZE
}
export function formatRecordingDuration(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function mediaErrorKey(error?: string): string {
const known = new Set([
'cancelled',
'capture_failed',
'invalid_media_type',
'invalid_upload',
'invalid_upload_token',
'missing_config',
'not_found',
'operation_in_progress',
'owner_changed',
'rate_limited',
'request_failed',
'request_timeout',
'unsupported',
'upload_failed',
'upload_timeout',
])
return known.has(error ?? '') ? (error as string) : 'request_failed'
}
+5 -1
View File
@@ -17,6 +17,7 @@ describe('preferences', () => {
notificationDurationSeconds: 14,
notifications: {
messages: { enabled: false, sounds: false },
clock: { enabled: false, sounds: false },
},
phoneScale: 110,
wallpaper: 'ember',
@@ -30,7 +31,10 @@ describe('preferences', () => {
enabled: false,
sounds: false,
})
expect(value.settings.notifications.clock.enabled).toBe(true)
expect(value.settings.notifications.clock).toEqual({
enabled: false,
sounds: false,
})
expect(value.settings.notifications.mail).toEqual({
enabled: true,
sounds: true,
+22 -10
View File
@@ -1,4 +1,5 @@
import type { PhoneAppId } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { cloneJsonData } from '@/utils/clone'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const PHONE_FRAME_IDS = [
@@ -33,7 +34,7 @@ export type PhonePreferencesV1 = {
notificationSound: NotificationSoundId
notificationDurationSeconds: number
notificationVolume: number
notifications: Record<PhoneAppId, AppNotificationPreferences>
notifications: Record<LaunchablePhoneAppId, AppNotificationPreferences>
phoneScale: number
ringtone: RingtoneId
ringtoneVolume: number
@@ -44,14 +45,25 @@ export type PhonePreferencesV1 = {
}
const DEFAULT_APP_NOTIFICATIONS: Record<
PhoneAppId,
LaunchablePhoneAppId,
AppNotificationPreferences
> = {
phone: { enabled: true, sounds: true },
messages: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
memory: { enabled: true, sounds: true },
'number-merge': { enabled: true, sounds: true },
minesweeper: { enabled: true, sounds: true },
'tower-stack': { enabled: true, sounds: true },
'sky-flappy': { enabled: true, sounds: true },
'neon-drop': { enabled: true, sounds: true },
citymarkt: { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
@@ -105,16 +117,16 @@ function readChoice<T extends string>(
function readNotifications(
value: unknown,
): Record<PhoneAppId, AppNotificationPreferences> {
): Record<LaunchablePhoneAppId, AppNotificationPreferences> {
const source =
value && typeof value === 'object'
? (value as Partial<
Record<PhoneAppId, Partial<AppNotificationPreferences>>
Record<LaunchablePhoneAppId, Partial<AppNotificationPreferences>>
>)
: {}
const notifications = structuredClone(DEFAULT_APP_NOTIFICATIONS)
const notifications = cloneJsonData(DEFAULT_APP_NOTIFICATIONS)
for (const appId of Object.keys(notifications) as PhoneAppId[]) {
for (const appId of Object.keys(notifications) as LaunchablePhoneAppId[]) {
notifications[appId] = {
enabled: readBoolean(
source[appId]?.enabled,
@@ -131,13 +143,13 @@ function readNotifications(
}
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
if (!raw) return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
try {
const parsed = JSON.parse(raw) as Partial<PhonePreferencesV1>
const settings = parsed.settings
if (parsed.version !== 1 || !settings || typeof settings !== 'object') {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
}
const defaults = DEFAULT_PHONE_PREFERENCES.settings
@@ -195,6 +207,6 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
version: 1,
}
} catch {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
}
}