Merge remote-tracking branch 'origin/dev' into benja/app-overhaul

# Conflicts:
#	frontend/src/stores/phone.ts
#	frontend/src/views/apps/CrewLinkApp.vue
This commit is contained in:
smx.pusha
2026-08-12 21:23:48 +02:00
90 changed files with 5258 additions and 705 deletions
+152 -16
View File
@@ -238,6 +238,9 @@ const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
const DEVELOPMENT_PHONE_SCALE = 1.25
const PHONE_PORTRAIT_WIDTH = 390
const PHONE_PORTRAIT_HEIGHT = 844
const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
@@ -293,11 +296,34 @@ const phoneBaseZoom = computed(
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
const phoneZoom = computed(() => {
const preferred =
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
if (isDevelopment) return preferred
const edgeGap = 24 * viewportScale.value
const shellWidth = phone.cameraLandscape
? PHONE_PORTRAIT_HEIGHT
: PHONE_PORTRAIT_WIDTH
const shellHeight = phone.cameraLandscape
? PHONE_PORTRAIT_WIDTH
: PHONE_PORTRAIT_HEIGHT
const viewportMaximum = Math.max(
0,
Math.min(
(window.innerWidth - edgeGap) / shellWidth,
(window.innerHeight - edgeGap) / shellHeight,
),
)
return Math.min(
viewportMaximum,
Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred),
)
})
const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
'--phone-zoom':
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
'--phone-zoom': phoneZoom.value,
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
@@ -317,6 +343,8 @@ let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
let phoneClosePending = false
let simPickerClosePending = false
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -431,6 +459,50 @@ async function hydrateDevelopmentPhone(): Promise<void> {
})
}
function openDevelopmentPayphonePreview(): void {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
currency: '$',
locales: {
busy: 'LINE BUSY',
call: 'CALL',
callEnded: 'CALL ENDED',
clear: 'Clear number',
close: 'Close payphone',
connected: 'CONNECTED',
cost: 'COST',
declined: 'CALL DECLINED',
delete: 'Delete digit',
dialing: 'DIALING',
disconnected: 'DISCONNECTED',
elapsed: 'TIME',
hangup: 'HANG UP',
insufficientFunds: 'OUT OF MONEY',
invalidNumber: 'ENTER A VALID NUMBER',
keypad: 'Dial pad',
noAnswer: 'NO ANSWER',
numberLabel: 'NUMBER TO CALL',
numberPlaceholder: 'Enter a phone number',
rate: '{currency}{price} / SEC',
ready: 'READY',
requestFailed: 'CALL COULD NOT BE STARTED',
ringing: 'RINGING',
subtitle: 'PUBLIC TELEPHONE',
title: 'PAYPHONE',
unavailable: 'NUMBER UNAVAILABLE',
voiceUnavailable: 'VOICE SERVICE UNAVAILABLE',
},
maxNumberLength: 10,
pricePerSecond: 2,
},
type: 'payphone:open',
},
}),
)
}
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
@@ -467,7 +539,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
activitySuspended.value = false
phone.close()
phone.endDeviceSession()
} else if (event.data?.type === 'app:suspend') {
activitySuspended.value = true
} else if (event.data?.type === 'app:resume') {
@@ -861,14 +933,69 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
}
async function closeSimPicker(): Promise<void> {
if (simPickerClosePending || !simPicker.value) return
simPickerClosePending = true
const closingPicker = simPicker.value
try {
const response = await nuiCall('sim:picker-close')
if (response.success && simPicker.value === closingPicker) {
simPicker.value = null
}
} finally {
simPickerClosePending = false
}
}
async function closePhone(): Promise<void> {
if (phoneClosePending || !phone.isOpen) return
phoneClosePending = true
const closingGeneration = phone.persistenceGeneration
const closingImei = phone.device?.imei ?? null
const closingToken = phone.deviceSessionToken
try {
await phone.flushDevicePersistence()
if (
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
const response = await nuiCall('close')
if (
!response.success ||
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
phone.endDeviceSession()
} finally {
phoneClosePending = false
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
if (event.key !== 'Escape') return
if (simPicker.value) {
event.preventDefault()
void closeSimPicker()
return
}
phone.close()
void nuiCall('close')
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -1013,7 +1140,7 @@ onMounted(() => {
window.addEventListener('resize', updateViewportScale)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready')
void nuiCall('ui:ready', { protocolVersion: 1 })
clockTicker = setInterval(() => {
const now = Date.now()
for (const alarm of clock.dueAlarms(now)) {
@@ -1062,24 +1189,31 @@ onMounted(() => {
number: '5551234567',
}
}
if (developmentParameters.has('payphonePreview')) {
openDevelopmentPayphonePreview()
}
}
})
watch(
() => route.params.appId,
(appId) => {
if (typeof appId === 'string' && isPhoneAppId(appId)) {
if (
phone.isOpen &&
phone.device?.imei &&
appStore.hydrated &&
typeof appId === 'string' &&
isPhoneAppId(appId)
) {
appStore.recordLaunch(appId)
}
},
)
watch(
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
void nuiCall('notification:focus', {
active: requiresAttention || activeCall !== null,
})
() => notifications.requiresAttention,
(requiresAttention) => {
void nuiCall('notification:focus', { active: requiresAttention })
},
)
@@ -1163,7 +1297,7 @@ onBeforeUnmount(() => {
v-if="simPicker"
:choices="simPicker.choices"
:number="simPicker.number"
@close="simPicker = null"
@close="closeSimPicker"
/>
<Transition name="phone-lift" appear>
<main
@@ -1222,7 +1356,9 @@ onBeforeUnmount(() => {
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--darkchat': route.params.appId === 'darkchat',
'phone-app--light': !phone.isDarkMode,
'phone-app--messages': route.params.appId === 'messages',
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
+47 -21
View File
@@ -14,9 +14,13 @@
.sim-picker {
position: relative;
width: min(58vh, 90vw);
max-height: 46vh;
overflow: clip;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
min-height: 0;
overflow: hidden;
padding: 2.2vh;
display: flex;
flex-direction: column;
border: 0.1vh solid rgb(255 255 255 / 12%);
border-radius: 0.9vh;
background: #050505;
@@ -56,11 +60,16 @@
.sim-picker__header {
display: flex;
flex: 0 0 auto;
justify-content: space-between;
gap: 2vh;
margin-bottom: 1.6vh;
}
.sim-picker__header > div {
min-width: 0;
}
.sim-picker__header h1,
.sim-picker__confirmation h2 {
margin: 0;
@@ -73,6 +82,7 @@
margin: 0.45vh 0 0;
color: #9ca3af;
font-size: 1.15vh;
overflow-wrap: anywhere;
}
.sim-picker__close {
@@ -80,6 +90,8 @@
place-items: center;
width: 2.8vh;
height: 2.8vh;
min-width: 32px;
min-height: 32px;
border: 0;
border-radius: 0.35vh;
background: #1dd1ce;
@@ -96,10 +108,12 @@
position: relative;
z-index: 1;
display: grid;
flex: 1 1 auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1vh;
max-height: 34vh;
overflow: auto;
min-height: 0;
max-height: 54vh;
overflow-y: auto;
}
.sim-picker__card {
@@ -140,30 +154,34 @@
}
.sim-picker__details strong {
overflow: hidden;
font-size: 1.2vh;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.sim-picker__details small {
color: #9ca3af;
font-size: 0.95vh;
overflow-wrap: anywhere;
}
.sim-picker__confirmation {
min-height: 0;
overflow-y: auto;
padding: 2vh 0 0;
text-align: center;
}
.sim-picker__confirmation > div {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1vh;
margin-top: 2vh;
}
.sim-picker__confirmation button {
min-height: 40px;
overflow-wrap: anywhere;
padding: 0.8vh 1.6vh;
border: 0.1vh solid rgb(255 255 255 / 18%);
border-radius: 0.45vh;
@@ -184,6 +202,13 @@
color: #ff453a;
font-size: 1vh;
text-align: center;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.sim-picker__cards {
grid-template-columns: 1fr;
}
}
:root {
font-family:
@@ -205,6 +230,7 @@ body,
margin: 0;
overflow: hidden;
background: transparent !important;
pointer-events: none;
user-select: none;
}
button,
@@ -885,13 +911,13 @@ button {
}
/* DarkChat is intentionally independent from the phone appearance setting. */
.phone-app:has(.darkchat-page),
.phone-app:has(.darkchat-page) .phone-app-window,
.phone-app:has(.darkchat-page) .phone-app-window__content {
.phone-app--darkchat,
.phone-app--darkchat .phone-app-window,
.phone-app--darkchat .phone-app-window__content {
background: #000 !important;
color-scheme: dark;
}
.phone-app:has(.darkchat-page) .phone-status-bar {
.phone-app--darkchat .phone-status-bar {
color: #fff !important;
--status-bar-color: #fff;
}
@@ -1592,7 +1618,7 @@ button {
font-size: 9px;
white-space: nowrap;
}
.darkchat-message:has(.darkchat-reactions) {
.darkchat-message {
margin-bottom: 12px;
}
.darkchat-replying {
@@ -3714,7 +3740,7 @@ button {
.clock-world-time {
margin-top: 8px;
font-variant-numeric: tabular-nums;
font-size: min(76px, 20cqw);
font-size: 74px;
font-weight: 200;
letter-spacing: -4px;
}
@@ -3861,7 +3887,7 @@ button {
.clock-digits {
margin: 70px 0 54px;
font-variant-numeric: tabular-nums;
font-size: min(52px, 14cqw);
font-size: 52px;
font-weight: 200;
}
.clock-sound-menu {
@@ -4242,11 +4268,11 @@ button {
background: #fff !important;
color: #111;
}
.phone-app:has(.messages-page) .phone-status-bar {
.phone-app--messages .phone-status-bar {
color: #080808;
text-shadow: none;
}
.phone-app:has(.messages-page)::before {
.phone-app--messages::before {
content: '';
position: absolute;
z-index: 60;
@@ -5458,7 +5484,7 @@ button {
}
}
/* iOS 26-style Liquid Glass refinements and complete media pickers. */
.phone-app:has(.messages-page) {
.messages-page {
--ios-glass: rgb(252 252 255 / 70%);
--ios-glass-strong: rgb(255 255 255 / 86%);
--ios-glass-border: rgb(255 255 255 / 72%);
@@ -5779,21 +5805,21 @@ button {
.messages-attachment--video small {
z-index: 2;
}
.messages-attachment--gif:has(img) {
.messages-attachment--gif {
background: #e9e9ed;
}
/* Konsta's k-app dark state drives the complete iOS Messages palette. */
.phone-app.dark:has(.messages-page) {
.phone-app.dark .messages-page {
--ios-glass: rgb(28 28 30 / 76%);
--ios-glass-strong: rgb(36 36 38 / 90%);
--ios-glass-border: rgb(255 255 255 / 12%);
color-scheme: dark;
}
.phone-app.dark:has(.messages-page)::before {
.phone-app--messages.dark::before {
background: #000;
}
.phone-app.dark:has(.messages-page) .phone-status-bar {
.phone-app--messages.dark .phone-status-bar {
color: #fff;
text-shadow: none;
}
+45 -17
View File
@@ -12,6 +12,10 @@ import { useMarketplaceStore } from '@/stores/marketplace'
import { useDarkChatStore } from '@/stores/darkchat'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
const props = withDefaults(
defineProps<{
@@ -33,6 +37,7 @@ const emit = defineEmits<{
dragstart: [event: PointerEvent]
edit: []
remove: []
reorder: [direction: ReorderDirection]
}>()
const phone = usePhoneStore()
@@ -65,6 +70,8 @@ const suppressClick = ref(false)
let holdTimer: number | undefined
let calendarTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
watch(
() => props.app.iconImage,
@@ -135,6 +142,9 @@ function clearHold(): void {
function onPointerDown(event: PointerEvent): void {
if (props.compact || event.button !== 0) return
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
@@ -173,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void {
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelPointerDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
}
releasePointerCapture()
}
function cancelPointerDrag(): void {
clearHold()
if (!isDragging.value) return
const wasDragging = isDragging.value
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
releasePointerCapture()
if (wasDragging) emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelPointerDrag)
function releasePointerCapture(): void {
if (
pointerTarget &&
pointerId !== null &&
pointerTarget.hasPointerCapture(pointerId)
) {
pointerTarget.releasePointerCapture(pointerId)
}
pointerTarget = null
pointerId = null
}
function onKeydown(event: KeyboardEvent): void {
if (!props.editMode) return
const direction = reorderDirectionFromKeyboard(event)
if (!direction) return
event.preventDefault()
event.stopPropagation()
emit('reorder', direction)
}
onMounted(() => {
@@ -214,7 +237,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearHold()
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
removeDragListeners()
releasePointerCapture()
})
</script>
@@ -234,10 +257,15 @@ onBeforeUnmount(() => {
type="button"
:aria-label="getPhoneAppLabel(app, phone.t)"
:aria-disabled="!app.route"
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
@click="launch"
@contextmenu.prevent
@keydown="onKeydown"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
@lostpointercapture="cancelPointerDrag"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@@ -447,6 +447,8 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
right: auto;
bottom: auto;
left: 50%;
width: 827px;
height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
+4 -1
View File
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
}
function closeFromEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') opened.value = false
if (event.key !== 'Escape' || !opened.value) return
event.preventDefault()
event.stopPropagation()
opened.value = false
}
onMounted(() => {
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Camera, Pause, Play } from 'lucide-vue-next'
import { Camera, Pause, Play, TriangleAlert } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessageType } from '@/types/messages'
type MessageAttachment = {
@@ -11,7 +12,9 @@ type MessageAttachment = {
}
const props = defineProps<{ message: MessageAttachment }>()
const phone = usePhoneStore()
const playing = ref(false)
const playbackFailed = ref(false)
const video = ref<HTMLVideoElement>()
const imageStyles: Record<string, string> = {
@@ -55,9 +58,18 @@ async function toggleVideo(): Promise<void> {
playing.value = !playing.value
return
}
if (video.value.paused) await video.value.play()
else video.value.pause()
playing.value = !video.value.paused
if (!video.value.paused) {
video.value.pause()
return
}
playbackFailed.value = false
try {
await video.value.play()
} catch (error) {
playing.value = false
playbackFailed.value = true
console.error('[Messages] Could not play the attached video.', error)
}
}
function durationLabel(milliseconds: number | null): string {
@@ -85,8 +97,13 @@ function durationLabel(milliseconds: number | null): string {
v-else-if="message.message_type === 'video'"
type="button"
class="messages-attachment messages-attachment--video"
:class="{ playing }"
:class="{ playing, 'messages-attachment--failed': playbackFailed }"
:style="{ background }"
:aria-label="
playbackFailed
? phone.t('Apps.photos.errors.unsupported')
: phone.t('Apps.photos.videoAlt')
"
@click="toggleVideo"
>
<video
@@ -95,15 +112,25 @@ function durationLabel(milliseconds: number | null): string {
:src="mediaUrl"
playsinline
preload="metadata"
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@error="playbackFailed = true"
/>
<span
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
v-else-if="playing"
:size="22"
fill="currentColor"
/><Play
v-else
:size="22"
fill="currentColor"
/></span>
<small>{{ durationLabel(message.media_duration_ms) }}</small>
<small v-if="playbackFailed">{{
phone.t('Apps.photos.errors.unsupported')
}}</small>
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<img
@@ -81,6 +81,7 @@ const initials = computed(() =>
<style scoped>
.message-contact-card {
width: min(258px, 100%);
width: min(258px, 72cqw);
overflow: hidden;
border: 1px solid rgb(255 255 255 / 48%);
+4 -2
View File
@@ -239,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
if (!visible.value) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
void close()
return
}
@@ -256,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
prepareButtonSounds()
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('keydown', onKeydown, true)
ticker = window.setInterval(() => {
now.value = Date.now()
}, 250)
@@ -264,7 +265,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keydown', onKeydown, true)
if (ticker !== undefined) window.clearInterval(ticker)
for (const sound of buttonSounds) {
sound.pause()
@@ -399,6 +400,7 @@ onBeforeUnmount(() => {
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
pointer-events: auto;
user-select: none;
}
+191 -45
View File
@@ -4,6 +4,11 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { UploadReady } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
import {
bindMediaRecorderError,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
@@ -12,6 +17,7 @@ type PendingVideo = { blob: Blob; fileName: string }
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
const maxPendingVideos = 3
const captureFps = 30
const maxCaptureEdge = 720
const portraitAspect = 3 / 4
@@ -23,12 +29,16 @@ let gameView: GameView | null = null
let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let recordingStarting = false
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
let recordingStartedAt = 0
let recordingGeneration = 0
let flushTimer: number | undefined
let removeRecorderErrorListener: (() => void) | null = null
function captureDimensions(): { height: number; width: number } {
return landscape
@@ -54,7 +64,11 @@ function ensureGameView(): GameView {
if (gameView && !gameView.isLost()) return gameView
gameView?.dispose()
const dimensions = captureDimensions()
gameView = createGameView(canvasRef.value)
gameView = createGameView(canvasRef.value, {
onContextRestored: () => {
if (recorder?.state === 'recording') startRenderLoop()
},
})
gameView.resize(
dimensions.width,
dimensions.height,
@@ -93,6 +107,7 @@ function resetRecording(): void {
chunks = []
lastChunkAt = 0
lastChunkTimecode = null
recordingStartedAt = 0
}
function stopTracks(): void {
@@ -103,8 +118,22 @@ function stopTracks(): void {
}
function cleanupRecording(): void {
if (recorder && recorder.state !== 'inactive') recorder.stop()
recordingGeneration += 1
recordingStarting = false
const activeRecorder = recorder
recorder = null
removeRecorderErrorListener?.()
removeRecorderErrorListener = null
if (activeRecorder) {
activeRecorder.ondataavailable = null
if (activeRecorder.state !== 'inactive') {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
}
}
}
stopTracks()
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
@@ -114,7 +143,7 @@ function cleanupRecording(): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (recorder || recordingStarting) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
{
@@ -129,7 +158,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
bitrateBps = Math.round(configuredBitrate * 1000)
}
startRenderLoop()
try {
startRenderLoop()
} catch (error) {
console.error('[Camera] Could not start game-view recording.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStarting = true
const generation = ++recordingGeneration
resetRecording()
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
@@ -138,15 +182,23 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
const acquiredMicrophoneStream =
await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
if (generation !== recordingGeneration) {
acquiredMicrophoneStream.getTracks().forEach((track) => track.stop())
videoStream.getTracks().forEach((track) => track.stop())
return
}
microphoneStream = acquiredMicrophoneStream
} catch {
videoStream.getTracks().forEach((track) => track.stop())
if (generation !== recordingGeneration) return
cleanupRecording()
window.postMessage(
{
@@ -162,16 +214,51 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
if (generation !== recordingGeneration) {
stopTracks()
stopRenderLoop()
return
}
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
try {
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
} catch (error) {
console.error('[Camera] Could not create the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'unsupported', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
},
)
recorder.ondataavailable = (event) => {
if (!event.data.size) return
const now = Date.now()
@@ -186,7 +273,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
lastChunkAt = now
chunks.push({ blob: event.data, durationMs })
}
recorder.start()
try {
recorder.start()
} catch (error) {
console.error('[Camera] Could not start the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStartedAt = Date.now()
recordingStarting = false
flushTimer = window.setInterval(() => {
if (recorder?.state === 'recording') recorder.requestData()
}, 1000)
@@ -196,37 +298,78 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
async function stopRecording(data: Record<string, unknown>): Promise<void> {
const correlationId = String(data.correlationId ?? '')
if (!recorder || recorder.state === 'inactive' || !correlationId) return
const generation = recordingGeneration
const activeRecorder = recorder
postRecordState(false, true)
recorder.requestData()
await new Promise((resolve) => window.setTimeout(resolve, 120))
recorder.stop()
await new Promise((resolve) => window.setTimeout(resolve, 120))
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
stopTracks()
recorder = null
stopRenderLoop()
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
resetRecording()
pendingVideos.set(correlationId, {
blob,
fileName: `camera-${correlationId}.webm`,
})
await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
const stopErrorListener = removeRecorderErrorListener
stopErrorListener?.()
if (removeRecorderErrorListener === stopErrorListener) {
removeRecorderErrorListener = null
}
try {
await stopMediaRecorder(activeRecorder)
if (generation !== recordingGeneration) return
const durationMs = Math.max(
chunks.reduce((sum, entry) => sum + entry.durationMs, 0),
recordingStartedAt ? Date.now() - recordingStartedAt : 0,
)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
if (generation !== recordingGeneration) return
setBoundedMapEntry(
pendingVideos,
correlationId,
{ blob, fileName: `camera-${correlationId}.webm` },
maxPendingVideos,
)
const response = await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
if (generation !== recordingGeneration) return
if (!response.success) {
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'request_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} catch (error) {
if (generation === recordingGeneration) {
console.error('[Camera] Could not finalize the video recording.', error)
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'capture_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} finally {
if (generation === recordingGeneration || recorder === activeRecorder) {
if (recorder === activeRecorder) {
recorder = null
}
stopTracks()
stopRenderLoop()
resetRecording()
}
}
}
async function renderFrames(view: GameView, count: number): Promise<void> {
@@ -381,6 +524,9 @@ function onMessage(event: MessageEvent): void {
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
}
}
@@ -93,6 +93,7 @@ watch(
<style scoped>
.shared-content-card {
width: min(262px, 100%);
width: min(262px, 73cqw);
overflow: hidden;
border: 1px solid rgb(60 60 67 / 13%);
@@ -42,7 +42,6 @@ async function insert(
}
function close(): void {
void nuiCall('sim:picker-close')
emit('close')
}
</script>
+45 -17
View File
@@ -31,6 +31,10 @@ import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetInstance } from '@/types/widgets'
import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import type { WeatherConditionId } from '@/types/weather'
import { WIDGET_SPANS } from '@/utils/widgetLayout'
@@ -50,6 +54,7 @@ const emit = defineEmits<{
dragstart: [event: PointerEvent]
menu: []
remove: []
reorder: [direction: ReorderDirection]
}>()
const phone = usePhoneStore()
@@ -68,6 +73,8 @@ let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let dragStartPage = 0
let dragPageWidth = 0
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
const weatherIcons: Record<WeatherConditionId, Component> = {
sunny: Sun,
@@ -173,6 +180,9 @@ function onPointerDown(event: PointerEvent): void {
) {
return
}
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
@@ -210,35 +220,48 @@ function beginDrag(event: PointerEvent): void {
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
}
releasePointerCapture()
}
function cancelDrag(): void {
clearHold()
if (!isDragging.value) return
const wasDragging = isDragging.value
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
releasePointerCapture()
if (wasDragging) emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelDrag)
function releasePointerCapture(): void {
if (
pointerTarget &&
pointerId !== null &&
pointerTarget.hasPointerCapture(pointerId)
) {
pointerTarget.releasePointerCapture(pointerId)
}
pointerTarget = null
pointerId = null
}
function onKeydown(event: KeyboardEvent): void {
if (!props.editMode) return
const direction = reorderDirectionFromKeyboard(event)
if (!direction) return
event.preventDefault()
event.stopPropagation()
emit('reorder', direction)
}
function openWidget(): void {
@@ -276,7 +299,7 @@ async function messageContact(phoneNumber: string): Promise<void> {
onBeforeUnmount(() => {
clearHold()
removeDragListeners()
releasePointerCapture()
})
</script>
@@ -300,9 +323,14 @@ onBeforeUnmount(() => {
:class="`home-widget--${instance.kind}`"
role="button"
tabindex="0"
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
@click="openWidget"
@contextmenu.prevent
@keydown="onKeydown"
@keydown.enter="openWidget"
@lostpointercapture="cancelDrag"
@pointercancel="cancelDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { useWidgetsStore } from '@/stores/widgets'
import type { ReorderDirection } from '@/utils/keyboard'
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
const props = defineProps<{
@@ -17,6 +18,7 @@ const emit = defineEmits<{
dragstart: [id: string, event: PointerEvent]
menu: [id: string]
remove: [id: string]
reorder: [id: string, direction: ReorderDirection]
}>()
const widgets = useWidgetsStore()
const rows = computed(() =>
@@ -51,6 +53,7 @@ const instances = computed(() =>
@dragstart="emit('dragstart', instance.id, $event)"
@menu="emit('menu', instance.id)"
@remove="emit('remove', instance.id)"
@reorder="emit('reorder', instance.id, $event)"
/>
</div>
</template>
@@ -69,12 +69,12 @@ watch(
</script>
<template>
<k-sheet
:opened="opened"
class="widget-config-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<div class="widget-config-sheet">
<k-sheet
:opened="opened"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
v-if="instance"
class="widget-config-page"
@@ -183,11 +183,12 @@ watch(
</section>
</div>
</k-page>
</k-sheet>
</k-sheet>
</div>
</template>
<style scoped>
:global(.widget-config-sheet) {
.widget-config-sheet :deep(.k-sheet) {
z-index: 115;
height: calc(100% - 22px);
overflow: hidden;
@@ -117,12 +117,12 @@ watch(
</script>
<template>
<k-sheet
:opened="opened"
class="widget-picker-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<div class="widget-picker-sheet">
<k-sheet
:opened="opened"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
class="widget-picker-page"
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
@@ -206,11 +206,12 @@ watch(
</p>
</div>
</k-page>
</k-sheet>
</k-sheet>
</div>
</template>
<style scoped>
:global(.widget-picker-sheet) {
.widget-picker-sheet :deep(.k-sheet) {
z-index: 110;
height: calc(100% - 22px);
overflow: hidden;
@@ -41,6 +41,9 @@ function select(value: string): void {
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
if (!isOpen.value) return
event.preventDefault()
event.stopPropagation()
isOpen.value = false
return
}
@@ -367,4 +367,15 @@ function relativeTime(timestamp: number): string {
.feather-actions .is-bookmarked {
color: #438cf5;
}
@supports not (color: color-mix(in srgb, white, black)) {
.feather-post:active {
background: rgb(127 127 127 / 4%);
}
.feather-follow {
border-color: var(--feather-blue, #1d9bf0);
}
.feather-media {
border-color: rgb(127 127 127 / 18%);
}
}
</style>
+23 -1
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
import { nuiCall } from '@/utils/nui'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
@@ -60,4 +60,26 @@ describe('banking store', () => {
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
it('does not let an older response overwrite the newest overview', async () => {
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
(resolve) => {
resolveOlder = resolve
},
)
const newest = { ...overview, bank: 23000 }
mockNuiCall
.mockReturnValueOnce(olderResponse)
.mockResolvedValueOnce({ data: newest, success: true })
const banking = useBankingStore()
const olderRequest = banking.load()
await banking.load()
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
await olderRequest
expect(banking.overview).toEqual(newest)
expect(banking.isLoading).toBe(false)
})
})
+17 -3
View File
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
error: '',
isLoading: false,
overview: null as BankingOverview | null,
pendingRequests: 0,
requestGeneration: 0,
}),
actions: {
async load(): Promise<boolean> {
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>('banking:overview')
this.isLoading = false
const response = await nuiCall<BankingOverview>('banking:overview').finally(
() => {
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
this.isLoading = this.pendingRequests > 0
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
amount: number,
phoneNumber?: string,
): Promise<NuiResponse<BankingOverview>> {
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
amount,
...(phoneNumber === undefined ? {} : { phoneNumber }),
}).finally(() => {
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
this.isLoading = this.pendingRequests > 0
})
this.isLoading = false
if (generation !== this.requestGeneration) return response
if (response.success && response.data) {
this.overview = response.data
this.error = ''
+103 -3
View File
@@ -1,9 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import type { MailCounts, MailListItem } from '@/types/mail'
import { nuiCall } from '@/utils/nui'
import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
@@ -45,7 +46,7 @@ describe('mail store', () => {
success: true,
})
.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(2)] },
data: { hasMore: false, items: [listItem(2)], offset: 0 },
success: true,
})
@@ -129,4 +130,103 @@ describe('mail store', () => {
expect(mail.folder).toBe('inbox')
expect(mail.search).toBe('')
})
it('ignores an older folder response after a newer navigation', async () => {
let resolveOlder!: (response: NuiResponse<MailListResponse>) => void
const olderResponse = new Promise<NuiResponse<MailListResponse>>(
(resolve) => {
resolveOlder = resolve
},
)
mockNuiCall
.mockReturnValueOnce(olderResponse)
.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(2)] },
success: true,
})
const mail = useMailStore()
const olderRequest = mail.loadFolder('inbox')
await mail.loadFolder('sent')
resolveOlder({
data: { hasMore: false, items: [listItem(1)], offset: 0 },
success: true,
})
await olderRequest
expect(mail.folder).toBe('sent')
expect(mail.items.map((item) => item.id)).toEqual([2])
expect(mail.loading).toBe(false)
})
it('ignores mailbox counts returned after the session was cleared', async () => {
let resolveCounts!: (response: NuiResponse<MailCounts>) => void
mockNuiCall.mockReturnValueOnce(
new Promise<NuiResponse<MailCounts>>((resolve) => {
resolveCounts = resolve
}),
)
const mail = useMailStore()
const bootstrap = mail.bootstrap('alex@ifruit.com')
await mail.bootstrap('')
resolveCounts({ data: counts, success: true })
await bootstrap
expect(mail.accountEmail).toBe('')
expect(mail.counts).toEqual({
drafts: 0,
inbox: 0,
sent: 0,
trash: 0,
unread: 0,
})
})
it('ignores a late login after the mailbox session was cleared', async () => {
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
mockNuiCall.mockReturnValueOnce(
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
resolveLogin = resolve
}),
)
const mail = useMailStore()
const account = useAccountStore()
const login = mail.login('alex', 'secret')
await mail.bootstrap('')
resolveLogin({
data: { devices: [], email: 'alex@ifruit.com' },
success: true,
})
await login
expect(mail.accountEmail).toBe('')
expect(account.email).toBe('')
})
it('ignores a late login after an external mailbox session change', async () => {
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
mockNuiCall
.mockReturnValueOnce(
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
resolveLogin = resolve
}),
)
.mockResolvedValueOnce({ data: counts, success: true })
const mail = useMailStore()
const account = useAccountStore()
const login = mail.login('alex', 'secret')
account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
await mail.bootstrap('morgan@ifruit.com')
resolveLogin({
data: { devices: [], email: 'alex@ifruit.com' },
success: true,
})
await login
expect(mail.accountEmail).toBe('morgan@ifruit.com')
expect(account.email).toBe('morgan@ifruit.com')
})
})
+44 -4
View File
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
const items = ref<MailListItem[]>([])
const loading = ref(false)
const search = ref('')
let authenticationGeneration = 0
let folderRequestGeneration = 0
let sessionGeneration = 0
function clearSession(): void {
authenticationGeneration += 1
sessionGeneration += 1
folderRequestGeneration += 1
accountEmail.value = ''
counts.value = emptyCounts()
items.value = []
hasMore.value = false
folder.value = 'inbox'
search.value = ''
loading.value = false
}
async function bootstrap(email: string): Promise<void> {
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
clearSession()
return
}
authenticationGeneration += 1
sessionGeneration += 1
folderRequestGeneration += 1
accountEmail.value = email
await refreshCounts()
}
async function login(email: string, password: string) {
const generation = ++authenticationGeneration
const response = await nuiCall<IfruitAccount>('mail:login', {
email,
password,
})
if (response.success && response.data) {
if (
generation === authenticationGeneration &&
response.success &&
response.data
) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
}
async function register(email: string, password: string) {
const generation = ++authenticationGeneration
const response = await nuiCall<IfruitAccount>('mail:register', {
email,
password,
})
if (response.success && response.data) {
if (
generation === authenticationGeneration &&
response.success &&
response.data
) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
}
async function logout(): Promise<void> {
const generation = ++authenticationGeneration
if (accountEmail.value) {
const response = await nuiCall('mail:logout')
if (generation !== authenticationGeneration) return
if (response.success) account.hydrate(null)
}
if (generation !== authenticationGeneration) return
clearSession()
}
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
nextSearch = '',
append = false,
): Promise<boolean> {
const generation = ++folderRequestGeneration
const session = sessionGeneration
loading.value = true
const offset = append ? items.value.length : 0
const response = await nuiCall<MailListResponse>('mail:list', {
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
offset,
search: nextSearch,
})
loading.value = false
if (generation === folderRequestGeneration) loading.value = false
if (
generation !== folderRequestGeneration ||
session !== sessionGeneration
) {
return false
}
if (!response.success || !response.data) return false
folder.value = nextFolder
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
}
async function refreshCounts(): Promise<void> {
const email = accountEmail.value
const session = sessionGeneration
const response = await nuiCall<MailCounts>('mail:counts')
if (response.success && response.data) counts.value = response.data
if (
session === sessionGeneration &&
email === accountEmail.value &&
response.success &&
response.data
) {
counts.value = response.data
}
}
async function openMessage(id: number): Promise<MailMessage | null> {
+23
View File
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_LOCK_SCREEN_NOTIFICATIONS,
useNotificationsStore,
type PhoneNotificationDevice,
} from '@/stores/notifications'
@@ -256,4 +257,26 @@ describe('notifications store', () => {
notifications.clearLockScreen()
expect(notifications.lockScreenNotifications).toEqual([])
})
it('bounds persisted lock screen history to the newest notifications', () => {
openPhone('111')
const notifications = useNotificationsStore()
const items = Array.from(
{ length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 },
(_, index) => ({
appId: 'mail' as const,
id: `saved-${index}`,
text: `Message ${index}`,
title: 'Mail',
}),
)
notifications.hydrate({ items, version: 1 }, '111')
expect(notifications.lockScreenNotifications).toHaveLength(
MAX_LOCK_SCREEN_NOTIFICATIONS,
)
expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59')
expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10')
})
})
+9 -4
View File
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
version: 1
}
export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
const stopToneHandles = new Map<string, () => void>()
const persistenceQueues = new Map<string, Promise<void>>()
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
for (const notification of stored) merged.set(notification.id, notification)
for (const notification of lockScreenQueues.value[imei] ?? [])
merged.set(notification.id, notification)
lockScreenQueues.value[imei] = [...merged.values()]
lockScreenQueues.value[imei] = [...merged.values()].slice(
-MAX_LOCK_SCREEN_NOTIFICATIONS,
)
persist(imei)
}
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
function remember(notification: PhoneNotification): void {
const imei = notification.device?.imei ?? phone.device?.imei
if (!imei) return
const notifications = lockScreenQueues.value[imei] ?? []
notifications.push(notification)
lockScreenQueues.value[imei] = notifications
lockScreenQueues.value[imei] = [
...(lockScreenQueues.value[imei] ?? []),
notification,
].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
persist(imei)
}
@@ -0,0 +1,170 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
function deferredResponse<T>(): {
promise: Promise<NuiResponse<T>>
resolve: (response: NuiResponse<T>) => void
} {
let resolve!: (response: NuiResponse<T>) => void
const promise = new Promise<NuiResponse<T>>((next) => {
resolve = next
})
return { promise, resolve }
}
function openPhone(imei: string, token: string, revision: number): void {
usePhoneStore().open({
device: {
data: { settings: { payload: {}, revision } },
imei,
name: `Phone ${imei}`,
sim: null,
},
token,
})
}
describe('phone device persistence scope', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not apply a late save response to a newer device session', async () => {
const stale = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(stale.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 2)
phone.saveDeviceNamespace('settings', { value: 'old' })
await Promise.resolve()
expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
imei: '111',
namespace: 'settings',
payload: { value: 'old' },
revision: 2,
sessionToken: 'session-a',
})
openPhone('222', 'session-b', 7)
stale.resolve({ data: { revision: 3 }, success: true })
await stale.promise
await Promise.resolve()
expect(phone.device?.imei).toBe('222')
expect(phone.deviceRevisions.settings).toBe(7)
})
it('drops queued writes from an obsolete device generation', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(first.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
openPhone('222', 'session-b', 0)
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
await Promise.resolve()
expect(mockNuiCall).toHaveBeenCalledTimes(1)
})
it('flushes every queued write after a normal visibility close', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
phone.close()
const flushed = phone.flushDevicePersistence()
first.resolve({ data: { revision: 1 }, success: true })
await flushed
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
imei: '111',
namespace: 'settings',
payload: { order: 2 },
revision: 1,
sessionToken: 'session-a',
})
expect(phone.deviceRevisions.settings).toBe(2)
})
it('keeps queued writes scoped across a same-session bootstrap update', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
openPhone('111', 'session-a', 1)
await phone.flushDevicePersistence()
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.settings).toBe(2)
})
it('waits for writes queued while a persistence flush is in progress', async () => {
const first = deferredResponse<{ revision: number }>()
const queuedDuringFlush = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(queuedDuringFlush.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
await Promise.resolve()
let flushCompleted = false
const flushed = phone.flushDevicePersistence().then(() => {
flushCompleted = true
})
phone.saveDeviceNamespace('widgets', { order: 2 })
await Promise.resolve()
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
expect(flushCompleted).toBe(false)
queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
await flushed
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.widgets).toBe(1)
})
})
+68 -8
View File
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
clampPhoneScale,
ensureAppNotificationPreferences,
parsePhonePreferences,
type AppNotificationPreferences,
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
}
const namespaceQueues = new Map<string, Promise<void>>()
let nextPersistenceSession = 0
const companiesFallbackLocales = {
name: 'Companies',
@@ -3688,11 +3690,14 @@ export const usePhoneStore = defineStore('phone', {
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record<string, number>,
deviceSessionToken: null as string | null,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
persistenceGeneration: 0,
persistenceSession: ++nextPersistenceSession,
security: {
enabled: false,
length: null,
@@ -3713,6 +3718,15 @@ export const usePhoneStore = defineStore('phone', {
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
if (
nextImei !== (this.device?.imei ?? null) ||
nextToken !== this.deviceSessionToken
) {
this.persistenceGeneration += 1
}
this.deviceSessionToken = nextToken
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
@@ -3723,6 +3737,13 @@ export const usePhoneStore = defineStore('phone', {
}
this.isOpen = true
},
endDeviceSession(): void {
this.close()
if (this.deviceSessionToken !== null) {
this.deviceSessionToken = null
this.persistenceGeneration += 1
}
},
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
@@ -3736,22 +3757,59 @@ export const usePhoneStore = defineStore('phone', {
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
const imei = this.device?.imei
if (!imei) {
console.error(
`[Phone persistence] Could not save ${namespace} without an active device.`,
)
return
}
const generation = this.persistenceGeneration
const session = this.persistenceSession
const token = this.deviceSessionToken
const queuedPayload = cloneJsonData(payload)
const queueKey = `${session}:${generation}:${imei}:${namespace}`
const isCurrentScope = (): boolean =>
this.persistenceSession === session &&
this.persistenceGeneration === generation &&
this.device?.imei === imei &&
this.deviceSessionToken === token
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
if (!isCurrentScope()) return
const response = await nuiCall<{ revision: number }>('device:save', {
imei,
namespace,
payload,
payload: queuedPayload,
revision: this.deviceRevisions[namespace] ?? 0,
sessionToken: token,
})
if (response.success && response.data) {
this.deviceRevisions[namespace] = response.data.revision
if (
isCurrentScope() &&
response.success &&
Number.isInteger(response.data?.revision) &&
Number(response.data?.revision) >= 0
) {
this.deviceRevisions[namespace] = Number(response.data?.revision)
}
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(namespace) === tracked)
namespaceQueues.delete(namespace)
if (namespaceQueues.get(queueKey) === tracked)
namespaceQueues.delete(queueKey)
})
namespaceQueues.set(namespace, tracked)
namespaceQueues.set(queueKey, tracked)
},
async flushDevicePersistence(): Promise<void> {
const imei = this.device?.imei
if (!imei) return
const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
while (true) {
const activeQueues = [...namespaceQueues.entries()]
.filter(([key]) => key.startsWith(queuePrefix))
.map(([, queue]) => queue)
if (!activeQueues.length) return
await Promise.all(activeQueues)
}
},
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
@@ -3777,7 +3835,9 @@ export const usePhoneStore = defineStore('phone', {
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
this.preferences.settings[key] = value
this.preferences.settings[key] = (
key === 'phoneScale' ? clampPhoneScale(Number(value)) : value
) as PhonePreferencesV1['settings'][K]
this.saveDeviceNamespace('settings', this.preferences)
},
setAlertVolumes(value: number): void {
+91 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { gameViewGeometry } from '@/utils/gameView'
import { createGameView, gameViewGeometry } from '@/utils/gameView'
describe('gameViewGeometry', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
@@ -60,3 +60,92 @@ describe('gameViewGeometry', () => {
])
})
})
describe('createGameView', () => {
it('recreates graphics resources and resumes after context restoration', () => {
const gl = {
ARRAY_BUFFER: 1,
CLAMP_TO_EDGE: 2,
COLOR_BUFFER_BIT: 4,
COMPILE_STATUS: 5,
DYNAMIC_DRAW: 6,
FLOAT: 7,
FRAGMENT_SHADER: 8,
LINK_STATUS: 9,
MIRRORED_REPEAT: 10,
NEAREST: 11,
REPEAT: 12,
RGBA: 13,
STATIC_DRAW: 14,
TEXTURE_2D: 15,
TEXTURE_MAG_FILTER: 16,
TEXTURE_MIN_FILTER: 17,
TEXTURE_WRAP_S: 18,
TEXTURE_WRAP_T: 19,
TRIANGLE_STRIP: 20,
UNSIGNED_BYTE: 21,
VERTEX_SHADER: 22,
attachShader: vi.fn(),
bindBuffer: vi.fn(),
bindTexture: vi.fn(),
bufferData: vi.fn(),
clear: vi.fn(),
clearColor: vi.fn(),
compileShader: vi.fn(),
createBuffer: vi.fn(() => ({})),
createProgram: vi.fn(() => ({})),
createShader: vi.fn(() => ({})),
createTexture: vi.fn(() => ({})),
deleteBuffer: vi.fn(),
deleteProgram: vi.fn(),
deleteShader: vi.fn(),
deleteTexture: vi.fn(),
drawArrays: vi.fn(),
enableVertexAttribArray: vi.fn(),
finish: vi.fn(),
getAttribLocation: vi.fn((_program, name: string) =>
name === 'a_position' ? 0 : 1,
),
getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
getProgramInfoLog: vi.fn(() => ''),
getProgramParameter: vi.fn(() => true),
getShaderInfoLog: vi.fn(() => ''),
getShaderParameter: vi.fn(() => true),
getUniformLocation: vi.fn(() => ({})),
linkProgram: vi.fn(),
shaderSource: vi.fn(),
texImage2D: vi.fn(),
texParameterf: vi.fn(),
uniform1i: vi.fn(),
useProgram: vi.fn(),
vertexAttribPointer: vi.fn(),
viewport: vi.fn(),
}
const canvas = Object.assign(new EventTarget(), {
getContext: () => gl,
height: 0,
width: 0,
}) as unknown as HTMLCanvasElement
const restored = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.spyOn(console, 'info').mockImplementation(() => undefined)
const view = createGameView(canvas, { onContextRestored: restored })
view.resize(540, 720, 1920, 1080, 2)
const lost = new Event('webglcontextlost', { cancelable: true })
canvas.dispatchEvent(lost)
expect(lost.defaultPrevented).toBe(true)
expect(view.isLost()).toBe(true)
canvas.dispatchEvent(new Event('webglcontextrestored'))
expect(view.isLost()).toBe(false)
expect(restored).toHaveBeenCalledOnce()
expect(gl.createProgram).toHaveBeenCalledTimes(2)
expect(canvas.width).toBe(540)
expect(canvas.height).toBe(720)
view.render()
expect(gl.drawArrays).toHaveBeenCalledOnce()
view.dispose()
})
})
+177 -78
View File
@@ -31,6 +31,8 @@ export interface GameView {
}
export interface GameViewOptions {
onContextLost?: () => void
onContextRestored?: () => void
preserveDrawingBuffer?: boolean
}
@@ -113,80 +115,180 @@ export function createGameView(
let lost = false
let disposed = false
let program: WebGLProgram | null = null
let positionBuffer: WebGLBuffer | null = null
let texcoordBuffer: WebGLBuffer | null = null
let texture: WebGLTexture | null = null
let lastSize: {
height: number
sourceHeight: number
sourceWidth: number
width: number
zoom: number
} | null = null
const releaseResources = (): void => {
if (positionBuffer) gl.deleteBuffer(positionBuffer)
if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer)
if (texture) gl.deleteTexture(texture)
if (program) gl.deleteProgram(program)
positionBuffer = null
texcoordBuffer = null
texture = null
program = null
}
const initializeResources = (): void => {
releaseResources()
const nextProgram = gl.createProgram()
if (!nextProgram) throw new Error('game_view_program_unavailable')
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
gl.attachShader(nextProgram, vertexShader)
gl.attachShader(nextProgram, fragmentShader)
gl.linkProgram(nextProgram)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(nextProgram)
gl.deleteProgram(nextProgram)
throw new Error(error || 'game_view_program_failed')
}
gl.useProgram(nextProgram)
const positionLocation = gl.getAttribLocation(nextProgram, 'a_position')
const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
gl.deleteProgram(nextProgram)
throw new Error('game_view_attributes_unavailable')
}
const nextPositionBuffer = gl.createBuffer()
const nextTexcoordBuffer = gl.createBuffer()
const nextTexture = gl.createTexture()
if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) {
if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer)
if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer)
if (nextTexture) gl.deleteTexture(nextTexture)
gl.deleteProgram(nextProgram)
throw new Error('game_view_resources_unavailable')
}
program = nextProgram
positionBuffer = nextPositionBuffer
texcoordBuffer = nextTexcoordBuffer
texture = nextTexture
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)
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)
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)
}
const applySize = (): void => {
if (!lastSize || !positionBuffer || !texcoordBuffer) return
const geometry = gameViewGeometry(
lastSize.sourceWidth,
lastSize.sourceHeight,
lastSize.width,
lastSize.height,
lastSize.zoom,
)
canvas.width = lastSize.width
canvas.height = lastSize.height
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, lastSize.width, lastSize.height)
}
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
options.onContextLost?.()
}
const onContextRestored = () => {
if (disposed) return
try {
initializeResources()
lost = false
applySize()
console.info('[Camera] Game-view WebGL context restored.')
options.onContextRestored?.()
} catch (error) {
lost = true
console.error('[Camera] Could not restore the game-view WebGL context.', error)
}
}
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),
canvas.addEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
try {
initializeResources()
} catch (error) {
canvas.removeEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
canvas.removeEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
releaseResources()
throw error
}
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,
@@ -198,11 +300,18 @@ export function createGameView(
onContextLost as EventListener,
false,
)
canvas.removeEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
if (!lost) releaseResources()
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
if (disposed || lost) return
if (disposed || lost || !program) return
gl.useProgram(program)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
@@ -214,25 +323,15 @@ export function createGameView(
sourceHeight = window.innerHeight,
zoom = 1,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
const geometry = gameViewGeometry(
lastSize = {
height,
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)
}
if (disposed || lost) return
applySize()
},
}
}
+10
View File
@@ -5,6 +5,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
HOME_GRID_PAGE_SIZE,
homeKeyboardTarget,
MAX_HOME_GRID_PAGES,
moveHomeApp,
parseHomeLayout,
@@ -134,6 +135,15 @@ describe('home layout', () => {
expect(moved.grid[4]).toBe('notes')
})
it('provides bounded keyboard reorder targets without wrapping rows', () => {
expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2)
expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull()
expect(homeKeyboardTarget(defaults, 'grid', 0, 'up')).toBeNull()
expect(homeKeyboardTarget(defaults, 'grid', 0, 'down')).toBe(4)
expect(homeKeyboardTarget(defaults, 'dock', 1, 'left')).toBe(0)
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
})
it('shifts occupied grid slots instead of replacing their apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
+30
View File
@@ -1,6 +1,8 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
import type { ReorderDirection } from '@/utils/keyboard'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_COLUMNS = 4
export const HOME_GRID_PAGE_SIZE = 20
export const MAX_HOME_GRID_PAGES = 5
@@ -304,3 +306,31 @@ export function moveHomeApp(
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
return next
}
export function homeKeyboardTarget(
layout: HomeLayout,
area: HomeArea,
sourceIndex: number,
direction: ReorderDirection,
): number | null {
const source = layout[area]
if (!source[sourceIndex]) return null
if (area === 'dock') {
if (direction !== 'left' && direction !== 'right') return null
const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1)
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
}
const column = sourceIndex % HOME_GRID_COLUMNS
if (direction === 'left' && column === 0) return null
if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null
const deltas: Record<ReorderDirection, number> = {
down: HOME_GRID_COLUMNS,
left: -1,
right: 1,
up: -HOME_GRID_COLUMNS,
}
const targetIndex = sourceIndex + deltas[direction]
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest'
import {
consumeEscape,
handleEnterAction,
reorderDirectionFromKeyboard,
} from '@/utils/keyboard'
describe('keyboard interaction', () => {
it('does not submit while an IME composition is active', () => {
const action = vi.fn()
const preventDefault = vi.fn()
expect(
handleEnterAction({ isComposing: true, preventDefault }, action),
).toBe(false)
expect(action).not.toHaveBeenCalled()
expect(preventDefault).not.toHaveBeenCalled()
})
it('prevents the completed Enter key and runs its action once', () => {
const action = vi.fn()
const preventDefault = vi.fn()
expect(
handleEnterAction({ isComposing: false, preventDefault }, action),
).toBe(true)
expect(preventDefault).toHaveBeenCalledOnce()
expect(action).toHaveBeenCalledOnce()
})
it('consumes only an unhandled Escape outside IME composition', () => {
const preventDefault = vi.fn()
const stopImmediatePropagation = vi.fn()
expect(
consumeEscape({
defaultPrevented: false,
isComposing: false,
key: 'Escape',
preventDefault,
stopImmediatePropagation,
}),
).toBe(true)
expect(preventDefault).toHaveBeenCalledOnce()
expect(stopImmediatePropagation).toHaveBeenCalledOnce()
expect(
consumeEscape({
defaultPrevented: false,
isComposing: true,
key: 'Escape',
preventDefault,
stopImmediatePropagation,
}),
).toBe(false)
})
it('blocks a second Escape owner on the same event target', () => {
const target = new EventTarget()
const rootHandler = vi.fn()
target.addEventListener('keydown', (event) => {
consumeEscape(event as KeyboardEvent)
})
target.addEventListener('keydown', rootHandler)
const event = new Event('keydown', { cancelable: true })
Object.defineProperties(event, {
isComposing: { value: false },
key: { value: 'Escape' },
})
target.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(rootHandler).not.toHaveBeenCalled()
})
it('maps only unmodified arrow keys to reorder directions', () => {
expect(
reorderDirectionFromKeyboard({
altKey: false,
ctrlKey: false,
isComposing: false,
key: 'ArrowLeft',
metaKey: false,
}),
).toBe('left')
expect(
reorderDirectionFromKeyboard({
altKey: false,
ctrlKey: true,
isComposing: false,
key: 'ArrowLeft',
metaKey: false,
}),
).toBeNull()
})
})
+47
View File
@@ -0,0 +1,47 @@
export type ReorderDirection = 'down' | 'left' | 'right' | 'up'
export function consumeEscape(
event: Pick<
KeyboardEvent,
| 'defaultPrevented'
| 'isComposing'
| 'key'
| 'preventDefault'
| 'stopImmediatePropagation'
>,
): boolean {
if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented) {
return false
}
event.preventDefault()
event.stopImmediatePropagation()
return true
}
export function handleEnterAction(
event: Pick<KeyboardEvent, 'isComposing' | 'preventDefault'>,
action: () => unknown,
): boolean {
if (event.isComposing) return false
event.preventDefault()
void action()
return true
}
export function reorderDirectionFromKeyboard(
event: Pick<
KeyboardEvent,
'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey'
>,
): ReorderDirection | null {
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) {
return null
}
const directions: Partial<Record<string, ReorderDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
}
return directions[event.key] ?? null
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import {
bindMediaRecorderError,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
class FakeRecorder extends EventTarget {
state: RecordingState = 'recording'
stop = vi.fn(() => {
this.state = 'inactive'
this.dispatchEvent(new Event('stop'))
})
}
describe('media recorder lifecycle', () => {
it('resolves from the recorder stop event', async () => {
const recorder = new FakeRecorder()
await stopMediaRecorder(recorder as unknown as MediaRecorder)
expect(recorder.stop).toHaveBeenCalledOnce()
expect(recorder.state).toBe('inactive')
})
it('runs recorder error cleanup only for the current generation', () => {
const staleRecorder = new FakeRecorder()
const currentRecorder = new FakeRecorder()
const cleanup = vi.fn()
let generation = 1
const unbindStale = bindMediaRecorderError(
staleRecorder as unknown as MediaRecorder,
() => generation === 1,
cleanup,
)
generation = 2
staleRecorder.dispatchEvent(new Event('error'))
expect(cleanup).not.toHaveBeenCalled()
unbindStale()
bindMediaRecorderError(
currentRecorder as unknown as MediaRecorder,
() => generation === 2,
cleanup,
)
currentRecorder.dispatchEvent(new Event('error'))
currentRecorder.dispatchEvent(new Event('error'))
expect(cleanup).toHaveBeenCalledOnce()
})
it('keeps pending recording buffers bounded and evicts the oldest', () => {
const pending = new Map<string, number>()
setBoundedMapEntry(pending, 'first', 1, 2)
setBoundedMapEntry(pending, 'second', 2, 2)
setBoundedMapEntry(pending, 'third', 3, 2)
expect([...pending.entries()]).toEqual([
['second', 2],
['third', 3],
])
})
})
+62
View File
@@ -0,0 +1,62 @@
export function bindMediaRecorderError(
recorder: MediaRecorder,
isCurrent: () => boolean,
onError: (event: Event) => void,
): () => void {
let bound = true
const handleError = (event: Event): void => {
if (!bound || !isCurrent()) return
bound = false
recorder.removeEventListener('error', handleError)
onError(event)
}
recorder.addEventListener('error', handleError)
return () => {
if (!bound) return
bound = false
recorder.removeEventListener('error', handleError)
}
}
export async function stopMediaRecorder(recorder: MediaRecorder): Promise<void> {
if (recorder.state === 'inactive') return
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
recorder.removeEventListener('stop', onStop)
recorder.removeEventListener('error', onError)
}
const onStop = (): void => {
cleanup()
resolve()
}
const onError = (): void => {
cleanup()
reject(new Error('media_recorder_stop_failed'))
}
recorder.addEventListener('stop', onStop, { once: true })
recorder.addEventListener('error', onError, { once: true })
try {
recorder.stop()
} catch (error) {
cleanup()
reject(error)
}
})
}
export function setBoundedMapEntry<Key, Value>(
entries: Map<Key, Value>,
key: Key,
value: Value,
maximumSize: number,
): void {
entries.delete(key)
entries.set(key, value)
while (entries.size > Math.max(0, maximumSize)) {
const oldest = entries.keys().next()
if (oldest.done) break
entries.delete(oldest.value)
}
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { musicEscapeLayer } from '@/utils/musicEscape'
const closedState = {
actionMenuOpened: false,
activeSheet: false,
addMenuOpened: false,
confirmDeletePlaylist: false,
confirmRemoveTrack: false,
playerOpened: false,
}
describe('music Escape ownership', () => {
it('owns Escape while either music popover is open', () => {
expect(
musicEscapeLayer({ ...closedState, addMenuOpened: true }),
).toBe('menu')
expect(
musicEscapeLayer({ ...closedState, actionMenuOpened: true }),
).toBe('menu')
})
it('keeps a real form sheet above menus and the player', () => {
expect(
musicEscapeLayer({
...closedState,
activeSheet: true,
addMenuOpened: true,
playerOpened: true,
}),
).toBe('sheet')
})
it('does not claim Escape with no music overlay open', () => {
expect(musicEscapeLayer(closedState)).toBeNull()
})
})
+22
View File
@@ -0,0 +1,22 @@
export type MusicEscapeLayer =
| 'delete-playlist-confirmation'
| 'menu'
| 'player'
| 'remove-track-confirmation'
| 'sheet'
export function musicEscapeLayer(state: {
actionMenuOpened: boolean
activeSheet: boolean
addMenuOpened: boolean
confirmDeletePlaylist: boolean
confirmRemoveTrack: boolean
playerOpened: boolean
}): MusicEscapeLayer | null {
if (state.confirmRemoveTrack) return 'remove-track-confirmation'
if (state.confirmDeletePlaylist) return 'delete-playlist-confirmation'
if (state.activeSheet) return 'sheet'
if (state.addMenuOpened || state.actionMenuOpened) return 'menu'
if (state.playerOpened) return 'player'
return null
}
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nuiCall } from '@/utils/nui'
describe('nuiCall', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
clearTimeout: globalThis.clearTimeout,
location: { search: '' },
setTimeout: globalThis.setTimeout,
})
vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('clears the request timeout after a successful callback', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { value: 1 }, success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
)
vi.stubGlobal('fetch', fetchMock)
await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({
data: { value: 1 },
success: true,
})
expect(vi.getTimerCount()).toBe(0)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3002/api/test',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
it('aborts a callback that never completes', async () => {
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
}),
),
)
const request = nuiCall('never-responds')
await vi.advanceTimersByTimeAsync(20_000)
await expect(request).resolves.toEqual({
error: 'request_timeout',
success: false,
})
expect(vi.getTimerCount()).toBe(0)
})
})
+11 -1
View File
@@ -1,4 +1,5 @@
const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone'
const requestTimeoutMs = 20_000
export type NuiResponse<T = unknown> = {
success: boolean
@@ -24,12 +25,15 @@ export async function nuiCall<T = unknown>(
undefined,
}
: data
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs)
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(requestData),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: controller.signal,
})
if (!response.ok) {
@@ -41,8 +45,14 @@ export async function nuiCall<T = unknown>(
const body = await response.text()
return body ? (JSON.parse(body) as NuiResponse<T>) : { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const message = controller.signal.aborted
? 'request_timeout'
: error instanceof Error
? error.message
: 'Unknown error'
console.error(`[NUI] ${endpoint} failed:`, error)
return { error: message, success: false }
} finally {
window.clearTimeout(timeoutId)
}
}
+11
View File
@@ -85,6 +85,17 @@ describe('preferences', () => {
expect(value.settings.screenBrightness).toBe(10)
})
it('keeps the phone above the minimum usable scale', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: { phoneScale: 50 },
}),
)
expect(value.settings.phoneScale).toBe(75)
})
it('preserves safe notification preferences for custom apps', () => {
const appId = 'example-app' as LaunchablePhoneAppId
const value = parsePhonePreferences(
+12 -6
View File
@@ -23,7 +23,7 @@ export const PHONE_FRAME_IDS = [
export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const
export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const
export const WALLPAPER_IDS = ['midnight', 'aurora', 'ember'] as const
export const PHONE_SCALE_MIN = 50
export const PHONE_SCALE_MIN = 75
export const PHONE_SCALE_MAX = 150
export const PHONE_SCALE_STEP = 5
@@ -197,6 +197,10 @@ export function ensureAppNotificationPreferences(
}
}
export function clampPhoneScale(value: number): number {
return Math.min(PHONE_SCALE_MAX, Math.max(PHONE_SCALE_MIN, value))
}
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
@@ -249,11 +253,13 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
100,
),
notifications: readNotifications(settings.notifications),
phoneScale: readNumber(
settings.phoneScale,
defaults.phoneScale,
PHONE_SCALE_MIN,
PHONE_SCALE_MAX,
phoneScale: clampPhoneScale(
readNumber(
settings.phoneScale,
defaults.phoneScale,
Number.MIN_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER,
),
),
ringtone: readChoice(
settings.ringtone,
+16
View File
@@ -9,6 +9,7 @@ import {
removeWidget,
resizeWidget,
widgetOccupiedCells,
widgetKeyboardTarget,
} from '@/utils/widgetLayout'
describe('widget layout', () => {
@@ -47,6 +48,21 @@ describe('widget layout', () => {
expect(next.instances).toHaveLength(layout.instances.length)
})
it('provides bounded keyboard targets for each widget size', () => {
const layout = createDefaultWidgetLayout()
const clock = layout.instances.find(
(instance) => instance.id === 'home-clock',
)!
const music = layout.instances.find(
(instance) => instance.id === 'home-music',
)!
expect(widgetKeyboardTarget(clock, 'right')).toEqual({ column: 1, row: 0 })
expect(widgetKeyboardTarget(clock, 'up')).toBeNull()
expect(widgetKeyboardTarget(music, 'right')).toBeNull()
expect(widgetKeyboardTarget(music, 'down')).toEqual({ column: 0, row: 3 })
})
it('allows a small widget in the center with app cells on both sides', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
+27
View File
@@ -6,6 +6,7 @@ import type {
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import type { ReorderDirection } from '@/utils/keyboard'
export const WIDGET_GRID_COLUMNS = 4
export const WIDGET_HOME_ROWS = 5
@@ -296,6 +297,32 @@ export function moveWidget(
return { instances: placed, version: 1 }
}
export function widgetKeyboardTarget(
instance: WidgetInstance,
direction: ReorderDirection,
): { column: number; row: number } | null {
const span = WIDGET_SPANS[instance.size]
const maximumColumn = WIDGET_GRID_COLUMNS - span.columns
const maximumRow = rowsForPage(instance.page) - span.rows
const target = {
column:
instance.column +
(direction === 'left' ? -1 : direction === 'right' ? 1 : 0),
row:
instance.row +
(direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
}
if (
target.column < 0 ||
target.column > maximumColumn ||
target.row < 0 ||
target.row > maximumRow
) {
return null
}
return target
}
export function resizeWidget(
layout: WidgetLayout,
id: string,
+78 -43
View File
@@ -17,13 +17,16 @@ import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import {
deleteHomePage as previewHomePageDelete,
HOME_GRID_PAGE_SIZE,
homeKeyboardTarget,
MAX_HOME_GRID_PAGES,
type HomeArea,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
WIDGET_GRID_COLUMNS,
widgetKeyboardTarget,
widgetOccupiedCells,
} from '@/utils/widgetLayout'
@@ -515,6 +518,14 @@ function stopWidgetDrag(): void {
clearWidgetDragPreview()
}
function reorderWidget(id: string, direction: ReorderDirection): void {
const instance = widgets.layout.instances.find((widget) => widget.id === id)
if (!instance) return
const target = widgetKeyboardTarget(instance, direction)
if (!target) return
widgets.move(id, instance.page, target.column, target.row)
}
function removeWidget(id: string): void {
widgets.remove(id)
if (widgetActionId.value === id) widgetActionId.value = null
@@ -620,6 +631,21 @@ function stopHomeDrag(): void {
draggingHomeApp.value = null
}
function reorderHomeApp(
area: HomeArea,
sourceIndex: number,
direction: ReorderDirection,
): void {
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
sourceIndex,
direction,
)
if (targetIndex === null) return
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
}
async function addHomePage(): Promise<void> {
if (addingHomePage.value) return
addingHomePage.value = true
@@ -704,6 +730,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
@reorder="reorderWidget"
/>
</div>
</section>
@@ -725,6 +752,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
@reorder="reorderWidget"
/>
<TransitionGroup
:name="
@@ -750,6 +778,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
/>
<div
v-else
@@ -946,6 +975,7 @@ watch(isEditablePage, (visible) => {
@dragstart="startHomeDrag('dock', appIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
@reorder="reorderHomeApp('dock', appIndex, $event)"
/>
<div
v-else
@@ -996,49 +1026,50 @@ watch(isEditablePage, (visible) => {
</k-button>
</nav>
<k-sheet
:opened="widgetActionId !== null"
class="widget-action-sheet"
@backdropclick="widgetActionId = null"
>
<div class="widget-sheet-handle" />
<h3>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</h3>
<k-list inset strong class="widget-action-list">
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
@click="openWidgetConfig"
>
<template #media><Pencil :size="20" /></template>
</k-list-item>
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
@click="activeWidget && removeWidget(activeWidget.id)"
>
<template #media><Trash2 :size="20" /></template>
</k-list-item>
</k-list>
<k-button
large
rounded
class="widget-action-cancel"
@click="widgetActionId = null"
<div class="widget-action-sheet">
<k-sheet
:opened="widgetActionId !== null"
@backdropclick="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</k-button>
</k-sheet>
<div class="widget-sheet-handle" />
<h3>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</h3>
<k-list inset strong class="widget-action-list">
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
@click="openWidgetConfig"
>
<template #media><Pencil :size="20" /></template>
</k-list-item>
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
@click="activeWidget && removeWidget(activeWidget.id)"
>
<template #media><Trash2 :size="20" /></template>
</k-list-item>
</k-list>
<k-button
large
rounded
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</k-button>
</k-sheet>
</div>
<WidgetPickerSheet
:opened="widgetPickerOpened"
@@ -1055,7 +1086,11 @@ watch(isEditablePage, (visible) => {
</template>
<style scoped>
:global(.widget-action-sheet) {
.widget-action-sheet {
display: contents;
}
.widget-action-sheet :deep(.k-sheet) {
z-index: 105;
padding: 9px 0 30px;
border-radius: 25px 25px 0 0;
+85 -28
View File
@@ -40,6 +40,7 @@ import type {
BankingTransactionKind,
} from '@/types/banking'
import type { PhoneContact } from '@/types/phone'
import { handleEnterAction } from '@/utils/keyboard'
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
type BankingTab = 'home' | 'activity'
@@ -153,7 +154,9 @@ function closeAction(): void {
function updateTarget(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) {
console.error('[banking] Phone number input emitted without an input target.')
console.error(
'[banking] Phone number input emitted without an input target.',
)
return
}
target.value = event.target.value
@@ -163,7 +166,9 @@ function updateTarget(event: Event): void {
function selectContact(contact: PhoneContact): void {
target.value = contact.phone_number
formError.value = ''
void nextTick(() => document.getElementById('banking-transfer-amount')?.focus())
void nextTick(() =>
document.getElementById('banking-transfer-amount')?.focus(),
)
}
function updateAmount(event: Event): void {
@@ -237,6 +242,7 @@ function focusableSheetElements(): HTMLElement[] {
function handleSheetKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeAction()
return
}
@@ -258,6 +264,15 @@ function handleSheetKeydown(event: KeyboardEvent): void {
}
}
function handleWindowKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !action.value || event.defaultPrevented) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
closeAction()
}
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
@@ -268,9 +283,8 @@ function errorMessage(code: string): string {
async function submitAction(): Promise<void> {
if (!action.value) return
const parsedAmount = Number(amount.value)
const phoneNumber = action.value === 'transfer'
? normalizePhoneNumber(target.value)
: undefined
const phoneNumber =
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
@@ -291,13 +305,17 @@ async function submitAction(): Promise<void> {
action.value = null
}
onMounted(() => void banking.load())
onMounted(() => {
window.addEventListener('keydown', handleWindowKeydown)
void banking.load()
})
watch(action, async (currentAction) => {
if (currentAction) {
previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
await nextTick()
document.getElementById('banking-transfer-target')?.focus()
return
@@ -307,6 +325,7 @@ watch(action, async (currentAction) => {
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleWindowKeydown)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
previousFocus?.focus()
})
@@ -379,12 +398,17 @@ onBeforeUnmount(() => {
</div>
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
<div class="banking-balance__trend">
<span>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</span>
<span>{{
formatMoney(totals.incoming - totals.outgoing, true)
}}</span>
{{ phone.t('Apps.banking.recentPeriod') }}
</div>
</k-glass>
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
<section
class="banking-actions"
:aria-label="phone.t('Apps.banking.actions')"
>
<k-glass
component="button"
type="button"
@@ -435,17 +459,27 @@ onBeforeUnmount(() => {
>
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions.slice(0, 5)"
:key="transaction.id"
v-for="transaction in banking.overview.transactions.slice(0, 5)"
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
<component
:is="transactionIcons[transaction.kind]"
:size="17"
/>
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
{{
formatMoney(
isIncoming(transaction.kind)
? transaction.amount
: -transaction.amount,
true,
)
}}
</b>
</template>
</k-list-item>
@@ -460,13 +494,18 @@ onBeforeUnmount(() => {
<template v-else>
<section class="banking-activity-hero">
<span>{{ phone.t('Apps.banking.activity') }}</span>
<strong>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</strong>
<strong>{{
formatMoney(totals.incoming - totals.outgoing, true)
}}</strong>
<small>{{ phone.t('Apps.banking.recentPeriod') }}</small>
</section>
<k-card :content-wrap="false" class="banking-card banking-chart-card">
<div class="banking-chart-legend">
<span><i class="is-incoming"></i>{{ phone.t('Apps.banking.incoming') }}</span>
<span
><i class="is-incoming"></i
>{{ phone.t('Apps.banking.incoming') }}</span
>
<span><i></i>{{ phone.t('Apps.banking.outgoing') }}</span>
</div>
<div class="banking-chart">
@@ -475,14 +514,19 @@ onBeforeUnmount(() => {
:key="day.date.getTime()"
class="banking-chart__day"
role="img"
:aria-label="phone.t('Apps.banking.chartDaySummary', {
day: day.label,
incoming: formatMoney(day.incoming),
outgoing: formatMoney(day.outgoing),
})"
:aria-label="
phone.t('Apps.banking.chartDaySummary', {
day: day.label,
incoming: formatMoney(day.incoming),
outgoing: formatMoney(day.outgoing),
})
"
>
<div>
<i class="is-incoming" :style="{ height: `${day.incomingHeight}%` }"></i>
<i
class="is-incoming"
:style="{ height: `${day.incomingHeight}%` }"
></i>
<i :style="{ height: `${day.outgoingHeight}%` }"></i>
</div>
<span>{{ day.label }}</span>
@@ -494,7 +538,10 @@ onBeforeUnmount(() => {
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.allTransactions') }}</h2>
</div>
<k-card :content-wrap="false" class="banking-card banking-transaction-card">
<k-card
:content-wrap="false"
class="banking-card banking-transaction-card"
>
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions"
@@ -503,11 +550,21 @@ onBeforeUnmount(() => {
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
<component
:is="transactionIcons[transaction.kind]"
:size="17"
/>
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
{{
formatMoney(
isIncoming(transaction.kind)
? transaction.amount
: -transaction.amount,
true,
)
}}
</b>
</template>
</k-list-item>
@@ -553,7 +610,7 @@ onBeforeUnmount(() => {
</k-toolbar-pane>
</k-tabbar>
<k-sheet :opened="Boolean(action)" class="banking-sheet" @backdropclick="closeAction">
<k-sheet :opened="Boolean(action)" @backdropclick="closeAction">
<section
v-if="action"
class="banking-sheet__content"
@@ -600,7 +657,7 @@ onBeforeUnmount(() => {
type="number"
:value="amount"
@input="updateAmount"
@keydown.enter="submitAction"
@keydown.enter="handleEnterAction($event, submitAction)"
/>
</k-list>
<div class="banking-contact-picker">
+25
View File
@@ -1404,6 +1404,31 @@ onBeforeUnmount(() => {
--k-button-bg-color: var(--billing-blue);
width: 100%;
}
@supports not (color: color-mix(in srgb, white, black)) {
.billing-navbar {
--k-navbar-bg-color: rgb(7 9 12 / 90%);
background: rgb(7 9 12 / 88%);
}
.billing-app--light .billing-navbar {
--k-navbar-bg-color: rgb(245 247 250 / 91%);
background: rgb(245 247 250 / 88%);
}
.billing-summary__item,
.billing-filter-panel,
.billing-invoice-card,
.billing-list-row,
.billing-search :deep(form),
.billing-detail__hero,
.billing-panel,
.billing-note {
background: var(--billing-panel);
}
.billing-detail__hero--paid {
background:
radial-gradient(circle at 50% 6%, rgb(72 199 111 / 18%), transparent 52%),
var(--billing-panel);
}
}
.billing-toast {
z-index: 50;
}
+19 -2
View File
@@ -53,6 +53,7 @@ const elapsed = ref('00:00')
const captures = ref<CaptureItem[]>([])
const latestMedia = ref<PhoneMedia | null>(null)
const gameCanvas = ref<HTMLCanvasElement | null>(null)
const gameViewUnavailable = ref(false)
const noticeText = ref('')
const videoBitrateKbps = ref(1500)
let shutterTimer: number | undefined
@@ -291,10 +292,26 @@ function resizeGameView(entry?: ResizeObserverEntry): void {
function startGameView(): void {
if (isDevelopment || !gameCanvas.value) return
gameView = createGameView(gameCanvas.value)
try {
gameView = createGameView(gameCanvas.value, {
onContextRestored: () => {
resizeGameView()
startGameViewRenderLoop()
},
})
} catch (error) {
gameViewUnavailable.value = true
console.error('[Camera] Game-view rendering is unavailable.', error)
return
}
resizeObserver = new ResizeObserver((entries) => resizeGameView(entries[0]))
resizeObserver.observe(gameCanvas.value)
resizeGameView()
startGameViewRenderLoop()
}
function startGameViewRenderLoop(): void {
if (renderFrameId !== undefined) return
const render = () => {
if (!gameView || gameView.isLost()) {
renderFrameId = undefined
@@ -434,7 +451,7 @@ onBeforeUnmount(() => {
>
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
<canvas
v-if="!isDevelopment"
v-if="!isDevelopment && !gameViewUnavailable"
ref="gameCanvas"
class="camera-game-view"
aria-hidden="true"
+17 -14
View File
@@ -94,6 +94,7 @@ import type {
CompanySummary,
} from '@/types/companies'
import type { PhoneMedia } from '@/types/media'
import { handleEnterAction } from '@/utils/keyboard'
import { nuiCall } from '@/utils/nui'
type CompaniesTab = 'directory' | 'requests' | 'work'
@@ -1764,7 +1765,7 @@ onBeforeUnmount(() => {
:placeholder="phone.t('Apps.companies.requests.replyPlaceholder')"
:value="threadDraft"
@input="threadDraft = messagebarValue($event)"
@keydown.enter.exact.prevent="sendThreadMessage"
@keydown.enter.exact="handleEnterAction($event, sendThreadMessage)"
>
<template #right>
<k-toolbar-pane class="ios:h-10">
@@ -2166,11 +2167,11 @@ onBeforeUnmount(() => {
</k-toolbar-pane>
</k-tabbar>
<k-sheet
:opened="requestSheetOpened"
class="companies-sheet"
@backdropclick="requestSheetOpened = false"
>
<div class="companies-sheet">
<k-sheet
:opened="requestSheetOpened"
@backdropclick="requestSheetOpened = false"
>
<section
v-if="requestSheetOpened && activeCompany"
ref="requestSheetContent"
@@ -2311,7 +2312,8 @@ onBeforeUnmount(() => {
<span v-else>{{ phone.t('Apps.companies.composer.send') }}</span>
</k-button>
</section>
</k-sheet>
</k-sheet>
</div>
<k-actions
v-if="workActionsOpened"
@@ -2356,11 +2358,11 @@ onBeforeUnmount(() => {
</k-actions-group>
</k-actions>
<k-sheet
:opened="assignmentSheetOpened"
class="companies-sheet companies-assignment-sheet"
@backdropclick="assignmentSheetOpened = false"
>
<div class="companies-sheet companies-assignment-sheet">
<k-sheet
:opened="assignmentSheetOpened"
@backdropclick="assignmentSheetOpened = false"
>
<section
v-if="assignmentSheetOpened"
class="companies-sheet__content"
@@ -2421,7 +2423,8 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.companies.assignment.confirm') }}
</k-button>
</section>
</k-sheet>
</k-sheet>
</div>
<k-dialog
:opened="cancelDialogOpened"
@@ -3143,7 +3146,7 @@ onBeforeUnmount(() => {
background: var(--company-red);
}
.companies-sheet :deep(> div:last-child) {
.companies-sheet :deep(.k-sheet) {
max-height: 88%;
border-radius: 24px 24px 0 0;
overflow: hidden;
+29 -10
View File
@@ -85,6 +85,7 @@ import type {
} from '@/types/crewlink'
import { copyText } from '@/utils/clipboard'
import { easyShareCrewLinkInviteCode } from '@/utils/easyshare'
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
@@ -912,7 +913,30 @@ function onCrewLinkMessage(event: MessageEvent): void {
if (event.data?.type === 'crewlink:changed') void crew.bootstrap()
}
function onKeydown(event: KeyboardEvent): void {
if (
!confirmAction.value &&
!sheet.value &&
!selectedMember.value &&
!selectedPing.value
) {
return
}
if (!consumeEscape(event)) return
if (confirmAction.value) {
cancelConfirmation()
} else if (sheet.value) {
closeSheet()
} else if (selectedMember.value) {
selectedMember.value = null
} else {
selectedPing.value = null
}
}
onMounted(async () => {
window.addEventListener('keydown', onKeydown, true)
const authSelection = messageMedia.consumeMany<AuthMediaContext>(
'crewlink:auth-avatar',
)
@@ -948,6 +972,7 @@ onBeforeUnmount(() => {
if (liveTimer) window.clearInterval(liveTimer)
if (toastTimer) window.clearTimeout(toastTimer)
if (pointerFrame) cancelAnimationFrame(pointerFrame)
window.removeEventListener('keydown', onKeydown, true)
window.removeEventListener('message', onCrewLinkMessage)
})
</script>
@@ -1020,7 +1045,7 @@ onBeforeUnmount(() => {
maxlength="20"
outline
@input="updateValue('username', $event)"
@keydown.enter="createProfile"
@keydown.enter="handleEnterAction($event, createProfile)"
/>
</k-list>
<p v-if="formError" class="crewlink-error" role="alert">
@@ -1627,11 +1652,7 @@ onBeforeUnmount(() => {
</k-tabbar>
</template>
<k-sheet
:opened="Boolean(sheet)"
class="crewlink-sheet"
@backdropclick="closeSheet"
>
<k-sheet :opened="Boolean(sheet)" @backdropclick="closeSheet">
<section
v-if="sheet"
class="crewlink-sheet__content"
@@ -1699,7 +1720,7 @@ onBeforeUnmount(() => {
maxlength="8"
outline
@input="updateValue('inviteCode', $event)"
@keydown.enter="joinGroup"
@keydown.enter="handleEnterAction($event, joinGroup)"
/></k-list>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<k-button
@@ -2028,7 +2049,7 @@ onBeforeUnmount(() => {
maxlength="20"
outline
@input="updateValue('username', $event)"
@keydown.enter="saveProfile"
@keydown.enter="handleEnterAction($event, saveProfile)"
/></k-list>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<k-button
@@ -2046,7 +2067,6 @@ onBeforeUnmount(() => {
<k-sheet
:opened="Boolean(selectedMember && !sheet)"
class="crewlink-sheet"
@backdropclick="selectedMember = null"
>
<section
@@ -2088,7 +2108,6 @@ onBeforeUnmount(() => {
<k-sheet
:opened="Boolean(selectedPing)"
class="crewlink-sheet"
@backdropclick="selectedPing = null"
>
<section
+21 -18
View File
@@ -68,6 +68,7 @@ import type { EasySharePayload } from '@/types/easyshare'
import type { MediaType, PhoneMedia } from '@/types/media'
import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import { handleEnterAction } from '@/utils/keyboard'
const VOICE_MAX_DURATION_MS = 60_000
const VOICE_MAX_BYTES = 270_000
@@ -1788,7 +1789,7 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 8%);
}
.dc-action-sheet {
.dc-action-sheet :deep(.k-sheet) {
max-height: 72%;
padding: 7px 0 32px;
overflow-y: auto;
@@ -1797,7 +1798,7 @@ onBeforeUnmount(() => {
background: #111113;
}
.dc-selection-sheet {
.dc-selection-sheet :deep(.k-sheet) {
z-index: 1300;
padding-top: 9px;
}
@@ -2332,7 +2333,7 @@ onBeforeUnmount(() => {
clear-button
@input="setIdentifier"
@clear="identifier = ''"
@keydown.enter.prevent="requestStart()"
@keydown.enter="handleEnterAction($event, requestStart)"
/></k-list>
<k-button
large
@@ -2601,7 +2602,7 @@ onBeforeUnmount(() => {
:placeholder="t('message')"
:colors="darkMessagebarColors"
@input="setDraft"
@keydown.enter.exact.prevent="sendText"
@keydown.enter.exact="handleEnterAction($event, sendText)"
>
<template #left
><k-link
@@ -2851,12 +2852,12 @@ onBeforeUnmount(() => {
>
</k-dialog>
<k-sheet
:opened="Boolean(selectedMessage)"
class="dc-action-sheet"
:colors="darkSheetColors"
@backdropclick="selectedMessage = null"
>
<div class="dc-action-sheet">
<k-sheet
:opened="Boolean(selectedMessage)"
:colors="darkSheetColors"
@backdropclick="selectedMessage = null"
>
<template v-if="selectedMessage">
<div class="dc-sheet-handle" />
<div class="dc-reaction-row">
@@ -2934,7 +2935,8 @@ onBeforeUnmount(() => {
>{{ phone.t('Common.cancel') }}</k-button
>
</template>
</k-sheet>
</k-sheet>
</div>
<k-dialog
:opened="reportOpen"
@@ -2981,12 +2983,12 @@ onBeforeUnmount(() => {
>
</k-dialog>
<k-sheet
:opened="selectionSheet !== null"
class="dc-action-sheet dc-selection-sheet"
:colors="darkSheetColors"
@backdropclick="selectionSheet = null"
>
<div class="dc-action-sheet dc-selection-sheet">
<k-sheet
:opened="selectionSheet !== null"
:colors="darkSheetColors"
@backdropclick="selectionSheet = null"
>
<div class="dc-sheet-handle" />
<h3>{{ selectionTitle }}</h3>
<k-list inset strong class="dc-action-list dc-selection-list">
@@ -3014,7 +3016,8 @@ onBeforeUnmount(() => {
@click="selectionSheet = null"
>{{ phone.t('Common.cancel') }}</k-button
>
</k-sheet>
</k-sheet>
</div>
<k-toast :opened="Boolean(toast)" position="center" class="dc-toast">{{
toast
}}</k-toast>
+96 -5
View File
@@ -270,9 +270,17 @@ function moveMediaPreview(direction: number): void {
function handleMediaPreviewKeydown(event: KeyboardEvent): void {
if (!mediaPreview.value) return
if (event.key === 'Escape') closeMediaPreview()
if (event.key === 'ArrowLeft') moveMediaPreview(-1)
if (event.key === 'ArrowRight') moveMediaPreview(1)
if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
closeMediaPreview()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
moveMediaPreview(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
moveMediaPreview(1)
}
}
function toast(path: string): void {
@@ -761,11 +769,11 @@ watch(
onBeforeUnmount(() => {
if (exploreSearchTimer !== undefined) window.clearTimeout(exploreSearchTimer)
if (networkSearchTimer !== undefined) window.clearTimeout(networkSearchTimer)
window.removeEventListener('keydown', handleMediaPreviewKeydown)
window.removeEventListener('keydown', handleMediaPreviewKeydown, true)
})
onMounted(async () => {
window.addEventListener('keydown', handleMediaPreviewKeydown)
window.addEventListener('keydown', handleMediaPreviewKeydown, true)
const selection =
messageMedia.consumeMany<ComposerContext>('feather:composer')
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
@@ -4915,4 +4923,87 @@ onMounted(async () => {
color: inherit;
background: var(--feather-panel);
}
@supports not (color: color-mix(in srgb, white, black)) {
.feather-navbar {
--k-navbar-bg-color: rgb(255 255 255 / 91%);
border-bottom-color: rgb(127 127 127 / 18%);
}
:global(.dark) .feather-navbar {
--k-navbar-bg-color: rgb(9 13 18 / 91%);
}
.dark.feather-app .feather-navbar {
--k-navbar-bg-color: rgb(0 0 0 / 90%);
background: rgb(0 0 0 / 88%);
}
.feather-app--active .feather-navbar {
--k-navbar-bg-color: rgb(18 23 27 / 91%);
background: rgb(18 23 27 / 88%);
}
.feather-app--active.feather-app--light .feather-navbar {
--k-navbar-bg-color: rgb(251 251 246 / 91%);
background: rgb(251 251 246 / 88%);
}
.feather-feed-tabs,
.feather-explore-head,
.feather-activity-head,
.feather-trend,
.feather-composer__tools,
.feather-section-title,
.feather-profile,
.feather-profile-tabs,
.feather-activity,
.feather-person {
border-color: rgb(127 127 127 / 18%);
}
.feather-trend:active,
.feather-profile__stats button,
.feather-report select,
.feather-report textarea,
.feather-app--active .feather-explore-search :deep(form) {
background: rgb(127 127 127 / 7%);
}
.feather-profile__stats button:active,
.feather-thread-reply__comment-target,
.feather-thread-title span,
.feather-app--active .feather-profile__stats span,
.feather-composer-media > header > span,
.feather-composer-media > header > b,
.feather-app--active .feather-follow-button--pending:hover,
.feather-app--active :deep(.feather-follow:hover),
.feather-composer-media__actions button:not(:disabled):hover {
background: rgb(29 155 240 / 13%);
}
.feather-report select,
.feather-report textarea,
.feather-app--active .feather-follow-button--following {
border-color: rgb(127 127 127 / 24%);
}
.feather-app--active :deep(.feather-post-glass),
.feather-network-list,
.feather-app--active .feather-thread-reply,
.feather-profile-suggestion,
.feather-edit__identity,
.feather-edit__photo,
.feather-edit__fields,
.feather-connections__tabs,
.feather-connections__list {
background: var(--feather-panel);
}
.feather-network-person__bio {
color: var(--feather-muted);
}
.feather-network-person__avatar .feather-avatar,
.feather-app--active .feather-follow-button--pending,
.feather-app--active .feather-profile__actions :deep(.k-button),
.feather-edit__photo-actions :deep(.k-button) {
border-color: var(--feather-blue);
}
.feather-compose-fab,
.feather-edit__avatar {
border-color: #70c5fa;
}
.feather-connections__remove {
border-color: #f04f65;
}
}
</style>
+23 -9
View File
@@ -85,6 +85,7 @@ import type { PhoneMedia } from '@/types/media'
import type { EasySharePayload } from '@/types/easyshare'
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import { handleEnterAction } from '@/utils/keyboard'
type FlareTab = 'discover' | 'explore' | 'likes' | 'matches' | 'profile'
type ExploreMode = 'all' | 'dates' | 'friends' | 'longTerm'
@@ -1145,7 +1146,7 @@ onBeforeUnmount(() => {
:value="draft"
:disabled="flare.sending"
@input="draft = eventValue($event)"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterAction($event, sendMessage)"
>
<template #left>
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
@@ -1847,11 +1848,8 @@ onBeforeUnmount(() => {
</div>
</div>
<k-sheet
:opened="choiceOpened"
class="flare-choice-sheet"
@backdropclick="closeChoice"
>
<div class="flare-choice-sheet">
<k-sheet :opened="choiceOpened" @backdropclick="closeChoice">
<section
id="flare-choice-sheet"
ref="choiceSheetContent"
@@ -1861,7 +1859,7 @@ onBeforeUnmount(() => {
:aria-modal="choiceOpened ? 'true' : undefined"
aria-labelledby="flare-choice-sheet-title"
:inert="!choiceOpened"
@keydown.esc="closeChoice"
@keydown.esc.stop.prevent="closeChoice"
>
<span class="flare-choice-sheet__grabber" aria-hidden="true" />
<header class="flare-choice-sheet__header">
@@ -1910,7 +1908,8 @@ onBeforeUnmount(() => {
</k-list-item>
</k-list>
</section>
</k-sheet>
</k-sheet>
</div>
<k-dialog :opened="unmatchDialog" @backdropclick="unmatchDialog = false">
<template #title>{{ phone.t('Apps.flare.unmatchTitle') }}</template>
@@ -2854,7 +2853,7 @@ onBeforeUnmount(() => {
line-height: 1.45;
}
:global(.flare-choice-sheet) {
.flare-choice-sheet :deep(.k-sheet) {
z-index: 70;
max-height: min(62%, 420px);
overflow-y: auto !important;
@@ -3097,4 +3096,19 @@ onBeforeUnmount(() => {
:global(.phone-app.dark .flare-action) {
box-shadow: none;
}
@supports not (color: color-mix(in srgb, white, black)) {
.flare-navbar {
border-bottom-color: rgb(127 127 127 / 18%);
}
.flare-photo-slot {
background: rgb(127 127 127 / 18%);
}
.flare-photo-grid :deep(.flare-photo-add) {
border-color: rgb(127 127 127 / 44%);
background: var(--flare-surface);
}
.flare-choice-sheet__grabber {
background: rgb(127 127 127 / 38%);
}
}
</style>
+156 -59
View File
@@ -17,6 +17,7 @@ import {
ShieldAlert,
Send,
Share2,
TriangleAlert,
UserRound,
Video,
X,
@@ -116,6 +117,7 @@ const publishing = ref(false)
const feedback = ref('')
const likedPulseId = ref<string | null>(null)
const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null)
const playbackFailedIds = ref(new Set<string>())
const profileDraft = ref({
accountType: 'person',
bio: '',
@@ -250,11 +252,66 @@ async function confirmLogout(): Promise<void> {
}
function setVideoElement(id: string, element: unknown): void {
if (element instanceof HTMLVideoElement) videoElements.set(id, element)
if (element instanceof HTMLVideoElement) {
videoElements.set(id, element)
return
}
videoElements.delete(id)
setPlaybackFailed(id, false)
}
function setMusicElement(id: string, element: unknown): void {
if (element instanceof HTMLAudioElement) musicElements.set(id, element)
if (element instanceof HTMLAudioElement) {
musicElements.set(id, element)
return
}
musicElements.delete(id)
}
function setPlaybackFailed(id: string, failed: boolean): void {
const next = new Set(playbackFailedIds.value)
if (failed) next.add(id)
else next.delete(id)
playbackFailedIds.value = next
}
async function playFeedVideo(
id: string,
element: HTMLVideoElement,
showNotice = false,
): Promise<boolean> {
setPlaybackFailed(id, false)
try {
await element.play()
} catch (error) {
setPlaybackFailed(id, true)
console.error(`[FlipTok] Could not play video ${id}.`, error)
if (showNotice) notify(t('errors.video_not_found'))
return false
}
const music = musicElements.get(id)
if (music) {
try {
await music.play()
} catch (error) {
console.error(`[FlipTok] Could not play music for video ${id}.`, error)
}
}
return true
}
function handleFeedVideoError(id: string, event: Event): void {
setPlaybackFailed(id, true)
const code = (event.currentTarget as HTMLVideoElement).error?.code
console.error(
`[FlipTok] Video ${id} could not be decoded or loaded${code ? ` (media error ${code})` : ''}.`,
)
}
function retryPlayback(video: FlipTokVideo): void {
const element = videoElements.get(video.id)
if (element) void playFeedVideo(video.id, element, true)
}
function chooseMusicTrack(trackId: string): void {
@@ -305,10 +362,8 @@ function observeVideos(): void {
otherAudio?.pause()
}
})
void video.play().catch(() => undefined)
const id = video.dataset.id
const music = id ? musicElements.get(id) : undefined
if (music) void music.play().catch(() => undefined)
if (id) void playFeedVideo(id, video)
if (id) void nuiCall('fliptok:view', { id })
} else {
video.pause()
@@ -327,8 +382,7 @@ function togglePlayback(video: FlipTokVideo): void {
if (!element) return
const music = musicElements.get(video.id)
if (element.paused) {
void element.play()
if (music) void music.play().catch(() => undefined)
void playFeedVideo(video.id, element, true)
} else {
element.pause()
music?.pause()
@@ -339,6 +393,7 @@ function prepareFeedVideo(
video: FlipTokVideo,
element: HTMLVideoElement,
): void {
setPlaybackFailed(video.id, false)
element.volume = (Number(video.original_volume) || 0) / 100
const start = Math.min(
(Number(video.trim_start_ms) || 0) / 1000,
@@ -943,6 +998,7 @@ onBeforeUnmount(() => {
@timeupdate="
enforceVideoTrim(video, $event.target as HTMLVideoElement)
"
@error="handleFeedVideoError(video.id, $event)"
/>
<audio
v-if="video.music_url"
@@ -959,6 +1015,17 @@ onBeforeUnmount(() => {
@click="handleVideoClick(video)"
@dblclick.prevent="handleVideoDoubleClick(video)"
/>
<button
v-if="playbackFailedIds.has(video.id)"
type="button"
class="video-playback-fallback"
:aria-label="`${t('errors.video_not_found')} ${phone.t('Common.start')}`"
@click.stop="retryPlayback(video)"
>
<TriangleAlert />
<strong>{{ t('errors.video_not_found') }}</strong>
<span>{{ phone.t('Common.start') }}</span>
</button>
<Transition name="double-like">
<Heart
v-if="likedPulseId === video.id"
@@ -1563,13 +1630,12 @@ onBeforeUnmount(() => {
</div>
</div>
<k-sheet
v-if="commentsOpen"
:opened="commentsOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="commentsOpen = false"
>
<div v-if="commentsOpen" class="fliptok-sheet">
<k-sheet
:opened="commentsOpen"
:colors="darkSheetColors"
@backdropclick="commentsOpen = false"
>
<div class="sheet-handle" />
<div class="comments-sheet">
<header>
@@ -1631,15 +1697,15 @@ onBeforeUnmount(() => {
</template>
</k-messagebar>
</div>
</k-sheet>
</k-sheet>
</div>
<k-sheet
v-if="actionsOpen"
:opened="actionsOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="actionsOpen = false"
><div class="sheet-handle" />
<div v-if="actionsOpen" class="fliptok-sheet">
<k-sheet
:opened="actionsOpen"
:colors="darkSheetColors"
@backdropclick="actionsOpen = false"
><div class="sheet-handle" />
<div class="action-sheet">
<k-list inset strong
><k-list-item
@@ -1657,15 +1723,15 @@ onBeforeUnmount(() => {
><k-button large rounded tonal @click="actionsOpen = false">{{
t('cancel')
}}</k-button>
</div></k-sheet
>
<k-sheet
v-if="reportSheetOpen"
:opened="reportSheetOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="reportSheetOpen = false"
>
</div></k-sheet
>
</div>
<div v-if="reportSheetOpen" class="fliptok-sheet">
<k-sheet
:opened="reportSheetOpen"
:colors="darkSheetColors"
@backdropclick="reportSheetOpen = false"
>
<div class="sheet-handle" />
<div class="selection-sheet report-sheet">
<h3>{{ t('report') }}</h3>
@@ -1703,14 +1769,14 @@ onBeforeUnmount(() => {
t('cancel')
}}</k-button>
</div>
</k-sheet>
<k-sheet
v-if="musicSheetOpen"
:opened="musicSheetOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="musicSheetOpen = false"
>
</k-sheet>
</div>
<div v-if="musicSheetOpen" class="fliptok-sheet">
<k-sheet
:opened="musicSheetOpen"
:colors="darkSheetColors"
@backdropclick="musicSheetOpen = false"
>
<div class="sheet-handle" />
<div class="selection-sheet">
<h3>{{ t('chooseSound') }}</h3>
@@ -1753,14 +1819,14 @@ onBeforeUnmount(() => {
t('cancel')
}}</k-button>
</div>
</k-sheet>
<k-sheet
v-if="visibilitySheetOpen"
:opened="visibilitySheetOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="visibilitySheetOpen = false"
><div class="sheet-handle" />
</k-sheet>
</div>
<div v-if="visibilitySheetOpen" class="fliptok-sheet">
<k-sheet
:opened="visibilitySheetOpen"
:colors="darkSheetColors"
@backdropclick="visibilitySheetOpen = false"
><div class="sheet-handle" />
<div class="selection-sheet">
<h3>{{ t('whoCanWatch') }}</h3>
<k-list inset strong
@@ -1786,15 +1852,15 @@ onBeforeUnmount(() => {
><k-button large rounded tonal @click="visibilitySheetOpen = false">{{
t('cancel')
}}</k-button>
</div></k-sheet
>
<k-sheet
v-if="accountTypeSheetOpen"
:opened="accountTypeSheetOpen"
class="fliptok-sheet"
:colors="darkSheetColors"
@backdropclick="accountTypeSheetOpen = false"
><div class="sheet-handle" />
</div></k-sheet
>
</div>
<div v-if="accountTypeSheetOpen" class="fliptok-sheet">
<k-sheet
:opened="accountTypeSheetOpen"
:colors="darkSheetColors"
@backdropclick="accountTypeSheetOpen = false"
><div class="sheet-handle" />
<div class="selection-sheet">
<h3>{{ t('accountType') }}</h3>
<k-list inset strong
@@ -1814,8 +1880,9 @@ onBeforeUnmount(() => {
><k-button large rounded tonal @click="accountTypeSheetOpen = false">{{
t('cancel')
}}</k-button>
</div></k-sheet
>
</div></k-sheet
>
</div>
<k-dialog
:opened="logoutDialogOpen"
@backdropclick="!logoutSubmitting && (logoutDialogOpen = false)"
@@ -1932,6 +1999,36 @@ onBeforeUnmount(() => {
rgba(0, 0, 0, 0.74)
);
}
.video-playback-fallback {
position: absolute;
z-index: 10;
top: 50%;
left: 50%;
width: min(230px, 72%);
display: grid;
justify-items: center;
gap: 7px;
border: 1px solid rgb(255 255 255 / 24%);
border-radius: 18px;
padding: 16px;
background: rgb(18 18 20 / 88%);
color: #fff;
text-align: center;
transform: translate(-50%, -50%);
}
.video-playback-fallback svg {
width: 28px;
height: 28px;
color: #ff9f0a;
}
.video-playback-fallback strong {
font-size: 12px;
}
.video-playback-fallback span {
color: #64a8ff;
font-size: 11px;
font-weight: 700;
}
.video-copy {
position: absolute;
left: 13px;
@@ -2623,7 +2720,7 @@ onBeforeUnmount(() => {
.done-button {
font-weight: 650;
}
.fliptok-sheet {
.fliptok-sheet :deep(.k-sheet) {
color: #f5f5f7;
}
.sheet-handle {
+65 -6
View File
@@ -84,10 +84,13 @@ const imageZoom = ref(1)
const imagePan = ref({ x: 0, y: 0 })
const landscapeViewer = ref(false)
const dragging = ref(false)
const videoPlaybackError = ref(false)
const dragStart = ref({ panX: 0, panY: 0, x: 0, y: 0 })
let observer: IntersectionObserver | null = null
let toastTimer: number | undefined
let pendingDeleteCorrelation = ''
let dragTarget: HTMLElement | null = null
let dragPointerId: number | null = null
const imageStyle = computed(() => ({
cursor:
@@ -227,6 +230,7 @@ function openMedia(entry: PhoneMedia): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = entry
videoPlaybackError.value = false
imageZoom.value = 1
imagePan.value = { x: 0, y: 0 }
}
@@ -254,6 +258,7 @@ function closeMedia(): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = null
videoPlaybackError.value = false
deleteDialogOpened.value = false
stopDragging()
}
@@ -295,6 +300,9 @@ function startDragging(event: PointerEvent): void {
setZoom(2)
return
}
dragTarget = event.currentTarget as HTMLElement
dragPointerId = event.pointerId
dragTarget.setPointerCapture(event.pointerId)
dragging.value = true
dragStart.value = {
panX: imagePan.value.x,
@@ -302,8 +310,6 @@ function startDragging(event: PointerEvent): void {
x: event.clientX,
y: event.clientY,
}
window.addEventListener('pointermove', moveImage)
window.addEventListener('pointerup', stopDragging)
}
function moveImage(event: PointerEvent): void {
@@ -316,8 +322,44 @@ function moveImage(event: PointerEvent): void {
function stopDragging(): void {
dragging.value = false
window.removeEventListener('pointermove', moveImage)
window.removeEventListener('pointerup', stopDragging)
if (
dragTarget &&
dragPointerId !== null &&
dragTarget.hasPointerCapture(dragPointerId)
) {
dragTarget.releasePointerCapture(dragPointerId)
}
dragTarget = null
dragPointerId = null
}
function moveImageWithKeyboard(event: KeyboardEvent): void {
if (imageZoom.value <= 1) return
const step = event.shiftKey ? 48 : 24
const offsets: Partial<Record<string, { x: number; y: number }>> = {
ArrowDown: { x: 0, y: -step },
ArrowLeft: { x: step, y: 0 },
ArrowRight: { x: -step, y: 0 },
ArrowUp: { x: 0, y: step },
}
const offset = offsets[event.key]
if (!offset) return
event.preventDefault()
event.stopPropagation()
imagePan.value = {
x: imagePan.value.x + offset.x,
y: imagePan.value.y + offset.y,
}
}
async function initializeVideo(event: Event): Promise<void> {
orientToMedia(event)
videoPlaybackError.value = false
try {
await (event.currentTarget as HTMLVideoElement).play()
} catch {
// The native controls remain visible when embedded CEF blocks autoplay.
}
}
async function deleteSelected(): Promise<void> {
@@ -569,18 +611,33 @@ onBeforeUnmount(() => {
:alt="phone.t('Apps.photos.photoAlt')"
:style="imageStyle"
draggable="false"
tabindex="0"
@load="orientToMedia"
@pointerdown="startDragging"
@pointermove="moveImage"
@pointerup="stopDragging"
@pointercancel="stopDragging"
@lostpointercapture="stopDragging"
@keydown="moveImageWithKeyboard"
@dblclick="setZoom(imageZoom === 1 ? 2 : 1)"
/>
<video
v-else
:src="selected.url"
controls
autoplay
playsinline
@loadedmetadata="orientToMedia"
@loadedmetadata="initializeVideo"
@error="videoPlaybackError = true"
></video>
<k-block
v-if="selected.mediaType === 'video' && videoPlaybackError"
strong
inset
class="gallery-error"
role="alert"
>
{{ phone.t('Apps.photos.errors.unsupported') }}
</k-block>
</div>
</div>
@@ -776,6 +833,8 @@ onBeforeUnmount(() => {
position: absolute;
top: 50%;
left: 50%;
width: 720px;
height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
+7 -6
View File
@@ -395,11 +395,11 @@ onBeforeUnmount(() => {
</p>
</div>
<k-sheet
:opened="Boolean(selectedVehicle)"
class="garage-sheet"
@backdropclick="selectedVehicle = null"
>
<div class="garage-sheet">
<k-sheet
:opened="Boolean(selectedVehicle)"
@backdropclick="selectedVehicle = null"
>
<section v-if="selectedVehicle" class="garage-detail">
<k-link
component="button"
@@ -499,7 +499,8 @@ onBeforeUnmount(() => {
<Share2 :size="18" />{{ phone.t('Apps.easyShare.share') }}
</k-button>
</section>
</k-sheet>
</k-sheet>
</div>
<k-dialog
:opened="Boolean(valetCandidate)"
class="garage-valet-confirm"
+9 -7
View File
@@ -36,6 +36,7 @@ import { useMapStore } from '@/stores/map'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MapMarker, MapMarkerColor } from '@/types/map'
import { handleEnterAction } from '@/utils/keyboard'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
@@ -632,11 +633,11 @@ onBeforeUnmount(() => {
</div>
</section>
<k-sheet
:opened="Boolean(draftCoords || selectedMarker)"
class="map-marker-sheet"
@backdropclick="closeMarkerSheet"
>
<div class="map-marker-sheet">
<k-sheet
:opened="Boolean(draftCoords || selectedMarker)"
@backdropclick="closeMarkerSheet"
>
<section
v-if="draftCoords"
class="map-marker-sheet__content"
@@ -656,7 +657,7 @@ onBeforeUnmount(() => {
maxlength="40"
outline
@input="updateMarkerLabel"
@keydown.enter="saveMarker"
@keydown.enter="handleEnterAction($event, saveMarker)"
/>
</k-list>
<span class="map-marker-sheet__label">{{
@@ -739,7 +740,8 @@ onBeforeUnmount(() => {
</template>
</k-button>
</section>
</k-sheet>
</k-sheet>
</div>
<k-toast :opened="Boolean(toastText)" position="center">
{{ toastText }}
+2 -1
View File
@@ -54,6 +54,7 @@ import { useMessagesStore } from '@/stores/messages'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import { handleEnterAction } from '@/utils/keyboard'
import { sortContactsByMessageRecency } from '@/utils/messages'
import type {
GifSearchResult,
@@ -1496,7 +1497,7 @@ onBeforeUnmount(() => {
:value="draft"
:disabled="sending"
@input="draft = eventValue($event)"
@keydown.enter.exact.prevent="sendTextMessage"
@keydown.enter.exact="handleEnterAction($event, sendTextMessage)"
>
<template #left>
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
+53 -21
View File
@@ -53,6 +53,8 @@ import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
import { easyShareMusicTarget } from '@/utils/easyshare'
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
import { musicEscapeLayer } from '@/utils/musicEscape'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet =
@@ -498,6 +500,31 @@ function updateVolume(event: Event): void {
music.setVolume(Number(eventValue(event)) / 100)
}
function onKeydown(event: KeyboardEvent): void {
const layer = musicEscapeLayer({
actionMenuOpened: actionMenuOpened.value,
activeSheet: Boolean(activeSheet.value),
addMenuOpened: addMenuOpened.value,
confirmDeletePlaylist: confirmDeletePlaylist.value,
confirmRemoveTrack: confirmRemoveTrack.value,
playerOpened: playerOpened.value,
})
if (!layer) return
if (!consumeEscape(event)) return
if (layer === 'remove-track-confirmation') {
cancelRemoveTrack()
} else if (layer === 'delete-playlist-confirmation') {
confirmDeletePlaylist.value = false
} else if (layer === 'sheet') {
closeSheet()
} else if (layer === 'menu') {
dismissMenus()
} else if (layer === 'player') {
playerOpened.value = false
}
}
watch(
() => music.playlists,
() => {
@@ -510,6 +537,7 @@ watch(
)
onMounted(async () => {
window.addEventListener('keydown', onKeydown, true)
await music.load()
const target = easyShareMusicTarget(
route.query.easyShareKind,
@@ -531,6 +559,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
window.removeEventListener('keydown', onKeydown, true)
})
</script>
@@ -1061,12 +1090,12 @@ onBeforeUnmount(() => {
</k-list>
</section>
<k-sheet
:opened="Boolean(activeSheet)"
class="music-form-sheet"
@backdropclick="closeSheet"
>
<section class="music-sheet-content">
<div class="music-form-sheet">
<k-sheet
:opened="Boolean(activeSheet)"
@backdropclick="closeSheet"
>
<section class="music-sheet-content">
<header>
<k-link component="button" @click="closeSheet">{{
phone.t(
@@ -1089,7 +1118,7 @@ onBeforeUnmount(() => {
type="url"
:value="youtubeUrl"
@input="youtubeUrl = eventValue($event)"
@keydown.enter="submitYouTube"
@keydown.enter="handleEnterAction($event, submitYouTube)"
/>
<k-list-input
:label="phone.t('Apps.music.youtubeTitle')"
@@ -1098,7 +1127,7 @@ onBeforeUnmount(() => {
:placeholder="phone.t('Apps.music.youtubeTitlePlaceholder')"
:value="youtubeTitle"
@input="youtubeTitle = eventValue($event)"
@keydown.enter="submitYouTube"
@keydown.enter="handleEnterAction($event, submitYouTube)"
/>
<k-list-input
:label="phone.t('Apps.music.youtubeArtist')"
@@ -1107,7 +1136,7 @@ onBeforeUnmount(() => {
:placeholder="phone.t('Apps.music.youtubeArtistPlaceholder')"
:value="youtubeArtist"
@input="youtubeArtist = eventValue($event)"
@keydown.enter="submitYouTube"
@keydown.enter="handleEnterAction($event, submitYouTube)"
/>
</k-list>
<p v-if="music.error" class="music-form-error" role="alert">
@@ -1256,7 +1285,7 @@ onBeforeUnmount(() => {
:placeholder="phone.t('Apps.music.playlistPlaceholder')"
:value="playlistName"
@input="playlistName = eventValue($event)"
@keydown.enter="submitPlaylist"
@keydown.enter="handleEnterAction($event, submitPlaylist)"
/>
</k-list>
<p v-if="music.error" class="music-form-error" role="alert">
@@ -1272,15 +1301,16 @@ onBeforeUnmount(() => {
<template v-else>{{ phone.t('Common.save') }}</template>
</k-button>
</template>
</section>
</k-sheet>
</section>
</k-sheet>
</div>
<k-sheet
:opened="playerOpened"
class="music-player-sheet"
@backdropclick="playerOpened = false"
>
<section v-if="music.currentTrack" class="music-player">
<div class="music-player-sheet">
<k-sheet
:opened="playerOpened"
@backdropclick="playerOpened = false"
>
<section v-if="music.currentTrack" class="music-player">
<header>
<k-link
component="button"
@@ -1383,8 +1413,9 @@ onBeforeUnmount(() => {
<p v-if="music.playbackError" class="music-player-error">
{{ phone.t('Apps.music.errors.playback_failed') }}
</p>
</section>
</k-sheet>
</section>
</k-sheet>
</div>
<k-dialog
:opened="confirmRemoveTrack"
@@ -1874,6 +1905,7 @@ onBeforeUnmount(() => {
.music-form-sheet,
.music-player-sheet {
--music-accent: #fa2d48;
display: contents;
color: var(--music-label);
}
@@ -1985,7 +2017,7 @@ onBeforeUnmount(() => {
color: #ff453a !important;
}
.music-player-sheet {
.music-player-sheet :deep(.k-sheet) {
background: rgb(22 22 25 / 96%) !important;
}
+2 -1
View File
@@ -50,7 +50,8 @@ const deleteActionColors = {
textMaterial: 'text-red-500',
}
const noteBodyStyle: CSSProperties = {
height: 'calc(100cqh - 210px)',
height: '617px',
maxHeight: 'calc(100% - 210px)',
resize: 'none',
}
const currentNote = computed(() =>
+40 -15
View File
@@ -348,6 +348,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.number-merge-header > div {
min-width: 0;
}
.number-merge-header span {
@@ -357,6 +362,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
overflow-wrap: anywhere;
}
.number-merge-header h1 {
@@ -364,6 +370,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-size: 28px;
line-height: 1;
letter-spacing: -1px;
overflow-wrap: anywhere;
}
.number-merge-header button,
@@ -383,11 +390,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-menu {
height: calc(100% - 55px);
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 13px;
overflow-y: auto;
padding: 12px 0;
text-align: center;
}
@@ -401,6 +411,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
border-radius: 29px;
background: #7e5147;
box-shadow: 0 17px 30px rgb(93 48 36 / 20%);
flex: 0 0 auto;
margin-top: auto;
transform: rotate(-2deg);
}
@@ -418,8 +430,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-hero__tile--4 { color: #fff7e4; background: #e96c2c; }
.number-merge-hero__tile--8 { color: #fff7e4; background: #713c31; }
.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; }
.number-merge-menu__intro p { max-width: 300px; margin: 6px 0 0; color: #8b675b; font-size: 14px; line-height: 1.45; }
.number-merge-menu__intro { width: 100%; }
.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; overflow-wrap: anywhere; }
.number-merge-menu__intro p { max-width: 300px; margin: 6px auto 0; color: #8b675b; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
.number-merge-records {
width: 100%;
@@ -429,6 +442,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-records div {
min-width: 0;
display: grid;
gap: 1px;
padding: 8px;
@@ -443,9 +457,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
overflow-wrap: anywhere;
}
.number-merge-records strong { font-size: 20px; }
.number-merge-records strong { min-width: 0; overflow-wrap: anywhere; font-size: 20px; }
.number-merge-menu__actions { width: 100%; display: grid; gap: 7px; }
@@ -453,11 +468,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-secondary,
.number-merge-danger {
min-height: 46px;
padding: 0 18px;
padding: 10px 18px;
border-radius: 14px;
font-size: 15px;
font-weight: 850;
cursor: pointer;
overflow-wrap: anywhere;
}
.number-merge-primary {
@@ -474,14 +490,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-how-to {
width: 100%;
flex: 0 0 auto;
margin-bottom: auto;
padding: 9px 15px;
border-radius: 14px;
color: #795549;
background: rgb(255 255 255 / 28%);
}
.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; }
.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; }
.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; overflow-wrap: anywhere; }
.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
.number-merge-how-to div {
margin: 7px 0 4px;
color: #a7472d;
@@ -489,7 +508,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 900;
letter-spacing: -0.2px;
}
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; overflow-wrap: anywhere; }
.number-merge-game {
position: absolute;
@@ -629,7 +648,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 8px;
padding: 22px;
border-radius: 20px;
@@ -637,12 +656,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
backdrop-filter: blur(5px);
color: #fff7ea;
text-align: center;
overflow-y: auto;
}
.number-merge-overlay > span { color: #ffc75b; font-size: 29px; font-weight: 900; }
.number-merge-overlay h2 { margin: 0; font-size: 25px; }
.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; }
.number-merge-overlay button { min-width: 160px; }
.number-merge-overlay > span { margin-top: auto; color: #ffc75b; font-size: 29px; font-weight: 900; }
.number-merge-overlay h2 { margin: 0; font-size: 25px; overflow-wrap: anywhere; }
.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; }
.number-merge-overlay button { min-width: min(160px, 100%); }
.number-merge-overlay .number-merge-link { margin-bottom: auto; }
.number-merge-overlay .number-merge-secondary { color: #fff1df; background: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 13%); }
.number-merge-link {
@@ -677,6 +698,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
inset: 0;
display: grid;
place-items: center;
min-height: 0;
overflow-y: auto;
padding: 30px;
background: rgb(54 29 25 / 54%);
backdrop-filter: blur(6px);
@@ -684,6 +707,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-confirm > div {
width: 100%;
max-height: calc(100% - 32px);
overflow-y: auto;
display: grid;
gap: 9px;
padding: 21px;
@@ -694,8 +719,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
text-align: center;
}
.number-merge-confirm h2 { margin: 0; font-size: 21px; }
.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; }
.number-merge-confirm h2 { margin: 0; font-size: 21px; overflow-wrap: anywhere; }
.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; }
.number-merge-danger { border: 0; color: #fff; background: #b64f39; }
button:active { transform: scale(0.97); }
+9 -8
View File
@@ -1456,11 +1456,11 @@ onBeforeUnmount(() => {
</template>
</k-page>
<k-sheet
:opened="editorOpened"
class="phone-contact-editor-sheet"
@backdropclick="editorOpened = false"
>
<div class="phone-contact-editor-sheet">
<k-sheet
:opened="editorOpened"
@backdropclick="editorOpened = false"
>
<section
class="phone-contact-editor"
:class="{ 'phone-contact-editor--light': !phone.isDarkMode }"
@@ -1629,7 +1629,8 @@ onBeforeUnmount(() => {
</k-button>
</div>
</section>
</k-sheet>
</k-sheet>
</div>
<k-dialog
:opened="blockDialogOpened"
@@ -3310,7 +3311,7 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.62);
}
.phone-contact-editor-sheet {
.phone-contact-editor-sheet :deep(.k-sheet) {
width: 100%;
height: calc(100% - 52px);
max-height: calc(100% - 52px);
@@ -3729,7 +3730,7 @@ onBeforeUnmount(() => {
box-shadow: inset 0 0 0 1px rgba(142, 142, 147, 0.18);
}
.phone-recent-row:has(button:hover),
.phone-recent-row:hover,
.phone-my-card:hover,
.phone-contact-row:hover {
border-radius: 14px;
@@ -2852,4 +2852,10 @@ onBeforeUnmount(() => {
transform: scale(1.28);
}
}
@supports not (color: color-mix(in srgb, white, black)) {
.ps-activity--unread {
background: rgb(10 132 255 / 10%);
}
}
</style>