mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 17:00:53 +00:00
ENH - update SMS branch from dev
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user