mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
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:
+152
-16
@@ -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,
|
||||
}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user