ADD - persist lock screen notifications

This commit is contained in:
smx.pusha
2026-08-10 17:49:47 +02:00
parent 7bd8462dc3
commit bc308f0f69
13 changed files with 704 additions and 35 deletions
+42 -2
View File
@@ -82,6 +82,10 @@ type SimPickerPayload = {
number: string number: string
} }
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
device?: PhoneNotificationDevicePayload
}
type MailEventData = { type MailEventData = {
counts?: MailCounts counts?: MailCounts
device?: PhoneNotificationDevicePayload device?: PhoneNotificationDevicePayload
@@ -265,6 +269,13 @@ function getViewportScale(): number {
function hydratePhone(payload: PhoneOpenPayload): void { function hydratePhone(payload: PhoneOpenPayload): void {
phone.open(payload) phone.open(payload)
if (payload.device?.imei) {
notifications.hydrate(
payload.device.data.notifications?.payload,
payload.device.imei,
)
notifications.hideDevicePreview(payload.device.imei)
}
account.hydrate(payload.account ?? null) account.hydrate(payload.account ?? null)
notes.hydrate(payload.notes ?? []) notes.hydrate(payload.notes ?? [])
clock.hydrate(payload.device?.data.alarms?.payload) clock.hydrate(payload.device?.data.alarms?.payload)
@@ -336,7 +347,20 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'app:resume') { } else if (event.data?.type === 'app:resume') {
activitySuspended.value = false activitySuspended.value = false
} else if (event.data?.type === 'notification:show' && event.data.data) { } else if (event.data?.type === 'notification:show' && event.data.data) {
notifications.show(event.data.data as PhoneNotificationInput) const data = event.data.data as NotificationEventData
const { device, ...input } = data
const notification: PhoneNotificationInput = input
if (
device &&
(!phone.isOpen || device.imei !== phone.device?.imei)
) {
notification.device = {
imei: device.imei,
name: device.name,
preferences: parsePhonePreferences(device.settings ?? null),
}
}
notifications.show(notification)
} else if (event.data?.type === 'mail:changed' && event.data.data) { } else if (event.data?.type === 'mail:changed' && event.data.data) {
const data = event.data.data as MailEventData const data = event.data.data as MailEventData
if (data.counts) mail.setCounts(data.counts) if (data.counts) mail.setCounts(data.counts)
@@ -749,6 +773,17 @@ function toggleControlCenter(): void {
controlCenterOpened.value = !controlCenterOpened.value controlCenterOpened.value = !controlCenterOpened.value
} }
function lockPhone(): void {
if (!phone.isOpen || isLocked.value) return
controlCenterOpened.value = false
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
isLocked.value = true
}
function unlockCamera(): void { function unlockCamera(): void {
if (phone.security.enabled) { if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera' pendingUnlockRoute.value = '/apps/camera'
@@ -975,7 +1010,9 @@ onBeforeUnmount(() => {
<PhoneStatusBar <PhoneStatusBar
v-if="!isLocked" v-if="!isLocked"
:control-center-opened="controlCenterOpened" :control-center-opened="controlCenterOpened"
lockable
@control-center="toggleControlCenter" @control-center="toggleControlCenter"
@lock="lockPhone"
/> />
<SpringboardView /> <SpringboardView />
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
@@ -995,7 +1032,10 @@ onBeforeUnmount(() => {
<Transition name="lock-screen"> <Transition name="lock-screen">
<PhoneLockScreen <PhoneLockScreen
v-if="isLocked" v-if="isLocked"
:notifications="notifications.lockScreenNotifications"
@camera="unlockCamera" @camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="notifications.dismissFromLockScreen"
@unlock="unlockPhone" @unlock="unlockPhone"
/> />
</Transition> </Transition>
@@ -1020,7 +1060,7 @@ onBeforeUnmount(() => {
/> />
</Transition> </Transition>
<PhoneNotifications <PhoneNotifications
:notification="notifications.current" :notification="phone.isOpen ? null : notifications.current"
@close="notifications.dismissCurrent()" @close="notifications.dismissCurrent()"
/> />
<div class="phone-display-dimmer" aria-hidden="true"></div> <div class="phone-display-dimmer" aria-hidden="true"></div>
+140 -6
View File
@@ -268,10 +268,6 @@ button {
transform: translateY(calc(100% - 190px)); transform: translateY(calc(100% - 190px));
transform-origin: right bottom; transform-origin: right bottom;
} }
.notification-phone-background {
position: absolute;
inset: 0;
}
.phone-device { .phone-device {
position: relative; position: relative;
width: 390px; width: 390px;
@@ -458,6 +454,19 @@ button {
width: calc(50% - 70px); width: calc(50% - 70px);
text-align: center; text-align: center;
} }
.phone-status-bar__time-button {
top: 0;
height: 48px;
border: 0;
padding: 17px 0 0;
color: inherit;
background: transparent;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
cursor: pointer;
pointer-events: auto;
}
.phone-status-bar__indicators { .phone-status-bar__indicators {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -569,7 +578,7 @@ button {
left: 30px; left: 30px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
min-height: 26px; min-height: 26px;
filter: drop-shadow(0 1px 2px #0009); filter: drop-shadow(0 1px 2px #0009);
} }
@@ -615,9 +624,123 @@ button {
0 2px 16px #0006; 0 2px 16px #0006;
filter: blur(0.24px); filter: blur(0.24px);
} }
.lock-screen__status-time {
color: #fff;
font-size: 14px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.lock-screen__notifications {
position: absolute;
z-index: 2;
top: 285px;
right: 18px;
bottom: 142px;
left: 18px;
display: grid;
align-content: start;
gap: 8px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.lock-screen__notifications::-webkit-scrollbar {
display: none;
}
.lock-screen__notifications-clear {
position: sticky;
z-index: 5;
top: 0;
justify-self: end;
border: 0;
border-radius: 999px;
padding: 5px 10px;
color: #fff;
background: #1c1c20b8;
box-shadow: 0 2px 10px #0004;
font-size: 11px;
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.lock-screen__notification {
position: relative;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
min-height: 72px;
border-radius: 18px;
padding: 11px 38px 11px 11px;
color: #fff;
background-color: rgb(28 28 32 / 72%);
text-shadow: 0 1px 3px #0009;
}
.lock-screen__notification-icon {
width: 42px;
height: 42px;
border-radius: 10px;
object-fit: contain;
}
.lock-screen__notification-copy {
min-width: 0;
}
.lock-screen__notification-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lock-screen__notification-heading strong {
overflow: hidden;
font-size: 13px;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.lock-screen__notification-heading span,
.lock-screen__notification-copy small {
color: #ffffffa6;
font-size: 10px;
line-height: 14px;
}
.lock-screen__notification-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lock-screen__notification-copy p {
display: -webkit-box;
overflow: hidden;
margin: 2px 0 0;
font-size: 12px;
line-height: 15px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.lock-screen__notification-close {
position: absolute;
top: 8px;
right: 8px;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 0;
border-radius: 50%;
padding: 0;
color: #fff;
background: #0007;
cursor: pointer;
}
.lock-screen__footer { .lock-screen__footer {
position: absolute; position: absolute;
z-index: 1; z-index: 3;
right: 0; right: 0;
bottom: 25px; bottom: 25px;
left: 0; left: 0;
@@ -656,6 +779,16 @@ button {
transform: rotate(45deg); transform: rotate(45deg);
animation: lock-chevron 1.8s ease-in-out infinite; animation: lock-chevron 1.8s ease-in-out infinite;
} }
.lock-screen.lock-screen-enter-active {
transform-origin: top left;
transition:
transform 0.44s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.28s ease-out;
}
.lock-screen.lock-screen-enter-from {
transform: translate3d(-28px, -22px, 0) scale(0.84);
opacity: 0;
}
.lock-screen-leave-active { .lock-screen-leave-active {
transition: transition:
transform 0.64s cubic-bezier(0.32, 0.72, 0, 1), transform 0.64s cubic-bezier(0.32, 0.72, 0, 1),
@@ -694,6 +827,7 @@ button {
.lock-screen__swipe-chevron { .lock-screen__swipe-chevron {
animation: none; animation: none;
} }
.lock-screen.lock-screen-enter-active,
.lock-screen-leave-active { .lock-screen-leave-active {
transition-duration: 0.18s; transition-duration: 0.18s;
} }
@@ -2,8 +2,8 @@
import { kApp } from 'konsta/vue' import { kApp } from 'konsta/vue'
import { computed, type CSSProperties } from 'vue' import { computed, type CSSProperties } from 'vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import type { PhoneNotification } from '@/stores/notifications' import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
@@ -61,11 +61,11 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
[`phone-app--${preferences.settings.graphicsMode}`]: true, [`phone-app--${preferences.settings.graphicsMode}`]: true,
}" }"
> >
<div <PhoneLockScreen
class="notification-phone-background" :notifications="[]"
:class="`wallpaper--${preferences.settings.wallpaper}`" :preferences="preferences"
></div> preview
<PhoneStatusBar /> />
<PhoneNotifications <PhoneNotifications
:notification="notification" :notification="notification"
@close="emit('close')" @close="emit('close')"
+81 -9
View File
@@ -1,18 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { kFab } from 'konsta/vue' import { kFab, kGlass } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole } from 'lucide-vue-next' import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue' import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { getPhoneApp } from '@/config/apps'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
const props = defineProps<{
notifications: PhoneNotification[]
preferences?: PhonePreferencesV1
preview?: boolean
}>()
const emit = defineEmits<{ const emit = defineEmits<{
camera: [] camera: []
clearNotifications: []
dismissNotification: [id: string]
unlock: [] unlock: []
}>() }>()
const phone = usePhoneStore() const phone = usePhoneStore()
const preferences = computed(() => props.preferences ?? phone.preferences)
const now = ref(new Date()) const now = ref(new Date())
const dragOffset = ref(0) const dragOffset = ref(0)
const dragging = ref(false) const dragging = ref(false)
@@ -59,7 +70,13 @@ const flashlightShortcutColors = computed(() =>
) )
function onPointerDown(event: PointerEvent): void { function onPointerDown(event: PointerEvent): void {
if ((event.target as HTMLElement).closest('button')) return if (props.preview) return
if (
(event.target as HTMLElement).closest(
'button, .lock-screen__notifications',
)
)
return
pointerStart = event.clientY pointerStart = event.clientY
pointerStartedAt = Date.now() pointerStartedAt = Date.now()
dragging.value = true dragging.value = true
@@ -87,11 +104,18 @@ function finishPointer(event: PointerEvent): void {
} }
function unlockWithKeyboard(event: KeyboardEvent): void { function unlockWithKeyboard(event: KeyboardEvent): void {
if (props.preview) return
if (event.key === 'Enter' || event.key === ' ') emit('unlock') if (event.key === 'Enter' || event.key === ' ') emit('unlock')
} }
function unlockFromWallpaper(event: MouseEvent): void { function unlockFromWallpaper(event: MouseEvent): void {
if ((event.target as HTMLElement).closest('button, [role="link"]')) return if (props.preview) return
if (
(event.target as HTMLElement).closest(
'button, [role="link"], .lock-screen__notifications',
)
)
return
emit('unlock') emit('unlock')
} }
@@ -119,12 +143,15 @@ onBeforeUnmount(() => {
<section <section
class="lock-screen" class="lock-screen"
:class="[ :class="[
`wallpaper--${phone.preferences.settings.wallpaper}`, `wallpaper--${preferences.settings.wallpaper}`,
{ 'lock-screen--dragging': dragging }, {
'lock-screen--dragging': dragging,
'lock-screen--preview': preview,
},
]" ]"
:style="dragStyle" :style="dragStyle"
:aria-label="phone.t('LockScreen.label')" :aria-label="phone.t('LockScreen.label')"
tabindex="0" :tabindex="preview ? -1 : 0"
@keydown="unlockWithKeyboard" @keydown="unlockWithKeyboard"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@pointermove="onPointerMove" @pointermove="onPointerMove"
@@ -135,21 +162,66 @@ onBeforeUnmount(() => {
<div class="lock-screen__shade" aria-hidden="true"></div> <div class="lock-screen__shade" aria-hidden="true"></div>
<header class="lock-screen__status"> <header class="lock-screen__status">
<time class="lock-screen__status-time">{{ time }}</time>
<PhoneStatusIndicators class="lock-screen__indicators" /> <PhoneStatusIndicators class="lock-screen__indicators" />
</header> </header>
<LockKeyhole <LockKeyhole
class="lock-screen__lock" class="lock-screen__lock"
:size="14" :size="14"
:stroke-width="1.8" :stroke-width="1.8"
aria-hidden="true" aria-hidden="true"
/> />
<div class="lock-screen__content"> <div class="lock-screen__content">
<time class="lock-screen__date">{{ date }}</time> <time class="lock-screen__date">{{ date }}</time>
<time class="lock-screen__time">{{ time }}</time> <time class="lock-screen__time">{{ time }}</time>
</div> </div>
<div class="lock-screen__footer"> <section
v-if="props.notifications.length"
class="lock-screen__notifications"
aria-live="polite"
>
<button
v-if="!preview && props.notifications.length > 1"
class="lock-screen__notifications-clear"
type="button"
@click.stop="emit('clearNotifications')"
>
{{ phone.t('Notifications.clearAll') }}
</button>
<k-glass
v-for="notification in props.notifications"
:key="notification.id"
class="lock-screen__notification"
:highlight="false"
>
<img
v-if="getPhoneApp(notification.appId)?.iconImage"
:src="getPhoneApp(notification.appId)?.iconImage"
alt=""
class="lock-screen__notification-icon"
/>
<div class="lock-screen__notification-copy">
<div class="lock-screen__notification-heading">
<strong>{{ notification.title }}</strong>
<span>{{ phone.t('Notifications.now') }}</span>
</div>
<small v-if="notification.subtitle">{{ notification.subtitle }}</small>
<p>{{ notification.text }}</p>
</div>
<button
class="lock-screen__notification-close"
type="button"
:aria-label="phone.t('Common.close')"
@click.stop="emit('dismissNotification', notification.id)"
>
<X :size="14" aria-hidden="true" />
</button>
</k-glass>
</section>
<div v-if="!preview" class="lock-screen__footer">
<nav class="lock-screen__shortcuts"> <nav class="lock-screen__shortcuts">
<k-fab <k-fab
component="button" component="button"
+18 -5
View File
@@ -4,10 +4,14 @@ import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore() const phone = usePhoneStore()
withDefaults(defineProps<{ controlCenterOpened?: boolean }>(), { withDefaults(
controlCenterOpened: false, defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
}) {
const emit = defineEmits<{ controlCenter: [] }>() controlCenterOpened: false,
lockable: false,
},
)
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
const time = ref('') const time = ref('')
let intervalId: number | undefined let intervalId: number | undefined
@@ -31,7 +35,16 @@ onBeforeUnmount(() => {
<template> <template>
<header class="phone-status-bar" :aria-label="phone.t('Common.phoneStatus')"> <header class="phone-status-bar" :aria-label="phone.t('Common.phoneStatus')">
<time class="phone-status-bar__time">{{ time }}</time> <button
v-if="lockable"
class="phone-status-bar__time phone-status-bar__time-button"
type="button"
:aria-label="phone.t('LockScreen.label')"
@click.stop="emit('lock')"
>
<time>{{ time }}</time>
</button>
<time v-else class="phone-status-bar__time">{{ time }}</time>
<button <button
class="phone-status-bar__indicators" class="phone-status-bar__indicators"
type="button" type="button"
+131 -1
View File
@@ -6,6 +6,7 @@ import {
type PhoneNotificationDevice, type PhoneNotificationDevice,
} from '@/stores/notifications' } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import { import {
DEFAULT_PHONE_PREFERENCES, DEFAULT_PHONE_PREFERENCES,
type PhonePreferencesV1, type PhonePreferencesV1,
@@ -15,7 +16,7 @@ vi.mock('@/utils/tones', () => ({
playPhoneTone: vi.fn(() => vi.fn()), playPhoneTone: vi.fn(() => vi.fn()),
})) }))
vi.mock('@/utils/nui', () => ({ vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(), nuiCall: vi.fn(async () => ({ success: true, data: { revision: 1 } })),
})) }))
function device( function device(
@@ -27,6 +28,17 @@ function device(
return { imei, name: `Phone ${imei}`, preferences } return { imei, name: `Phone ${imei}`, preferences }
} }
function openPhone(imei: string): void {
usePhoneStore().open({
device: {
data: {},
imei,
name: `Phone ${imei}`,
sim: null,
},
})
}
describe('notifications store', () => { describe('notifications store', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
@@ -34,6 +46,7 @@ describe('notifications store', () => {
matchMedia: vi.fn(() => ({ matches: false })), matchMedia: vi.fn(() => ({ matches: false })),
}) })
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.mocked(nuiCall).mockClear()
}) })
afterEach(() => { afterEach(() => {
@@ -127,4 +140,121 @@ describe('notifications store', () => {
expect(criticalId).not.toBeNull() expect(criticalId).not.toBeNull()
expect(notifications.current?.text).toBe('Timer finished') expect(notifications.current?.text).toBe('Timer finished')
}) })
it('persists a notification immediately while the phone is closed', async () => {
const notifications = useNotificationsStore()
notifications.show({
appId: 'mail',
device: device('111'),
text: 'Store while closed',
title: 'Mail',
})
await Promise.resolve()
await Promise.resolve()
expect(nuiCall).toHaveBeenCalledWith('notifications:save', {
imei: '111',
payload: {
items: [
expect.objectContaining({
appId: 'mail',
text: 'Store while closed',
title: 'Mail',
}),
],
version: 1,
},
})
})
it('keeps notifications on the lock screen after the banner expires', () => {
openPhone('111')
const notifications = useNotificationsStore()
notifications.show({
appId: 'mail',
text: 'Persistent lock screen message',
title: 'Mail',
})
vi.advanceTimersByTime(
DEFAULT_PHONE_PREFERENCES.settings.notificationDurationSeconds * 1000,
)
expect(notifications.current).toBeNull()
expect(notifications.lockScreenNotifications[0].text).toBe(
'Persistent lock screen message',
)
})
it('hides the matching device preview when that phone opens', () => {
const notifications = useNotificationsStore()
notifications.show({
appId: 'mail',
device: device('111'),
text: 'Move to lock screen',
title: 'Mail',
})
openPhone('111')
notifications.hideDevicePreview('111')
expect(notifications.devicePreviews).toEqual([])
expect(notifications.lockScreenNotifications[0].text).toBe(
'Move to lock screen',
)
})
it('dismisses a lock screen notification everywhere', () => {
openPhone('111')
const notifications = useNotificationsStore()
const id = notifications.show({
appId: 'mail',
text: 'Dismiss me',
title: 'Mail',
})
notifications.dismissFromLockScreen(id!)
expect(notifications.current).toBeNull()
expect(notifications.lockScreenNotifications).toEqual([])
})
it('hydrates persisted notifications and only removes them explicitly', () => {
const phone = usePhoneStore()
phone.open({
device: {
data: {
notifications: {
payload: {
items: [
{
appId: 'mail',
id: 'saved-notification',
text: 'Saved message',
title: 'Mail',
},
],
version: 1,
},
revision: 3,
},
},
imei: '111',
name: 'Phone 111',
sim: null,
},
})
const notifications = useNotificationsStore()
notifications.hydrate(
phone.device?.data.notifications?.payload,
'111',
)
vi.advanceTimersByTime(60_000)
expect(notifications.lockScreenNotifications[0].text).toBe('Saved message')
notifications.clearLockScreen()
expect(notifications.lockScreenNotifications).toEqual([])
})
}) })
+142 -5
View File
@@ -1,8 +1,10 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps' import type { LaunchablePhoneAppId } from '@/types/apps'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences' import type { PhonePreferencesV1 } from '@/utils/preferences'
import { playPhoneTone, type PhoneToneId } from '@/utils/tones' import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
@@ -27,13 +29,25 @@ export type PhoneNotification = PhoneNotificationInput & {
id: string id: string
} }
type PersistedPhoneNotification = Pick<
PhoneNotification,
'appId' | 'id' | 'subtitle' | 'text' | 'title'
>
type PersistedNotificationsV1 = {
items: PersistedPhoneNotification[]
version: 1
}
const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>() const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
const stopToneHandles = new Map<string, () => void>() const stopToneHandles = new Map<string, () => void>()
const persistenceQueues = new Map<string, Promise<void>>()
export const useNotificationsStore = defineStore('notifications', () => { export const useNotificationsStore = defineStore('notifications', () => {
const phone = usePhoneStore() const phone = usePhoneStore()
const queue = ref<PhoneNotification[]>([]) const queue = ref<PhoneNotification[]>([])
const deviceQueue = ref<PhoneNotification[]>([]) const deviceQueue = ref<PhoneNotification[]>([])
const lockScreenQueues = ref<Record<string, PhoneNotification[]>>({})
const current = computed(() => queue.value[0] ?? null) const current = computed(() => queue.value[0] ?? null)
const devicePreviews = computed(() => { const devicePreviews = computed(() => {
const devices = new Set<string>() const devices = new Set<string>()
@@ -45,6 +59,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
}) })
}) })
const isPeeking = computed(() => !!current.value && !phone.isOpen) const isPeeking = computed(() => !!current.value && !phone.isOpen)
const lockScreenNotifications = computed(() => {
const imei = phone.device?.imei
if (!imei) return []
return [...(lockScreenQueues.value[imei] ?? [])].reverse()
})
const requiresAttention = computed( const requiresAttention = computed(
() => () =>
!phone.isOpen && !phone.isOpen &&
@@ -52,6 +71,93 @@ export const useNotificationsStore = defineStore('notifications', () => {
devicePreviews.value.some((notification) => notification.persistent)), devicePreviews.value.some((notification) => notification.persistent)),
) )
function persist(imei: string): void {
const items = (lockScreenQueues.value[imei] ?? []).map(
({ appId, id, subtitle, text, title }) => ({
appId,
id,
...(subtitle ? { subtitle } : {}),
text,
title,
}),
)
const previous = persistenceQueues.get(imei) ?? Promise.resolve()
const queued = previous.then(async () => {
const response = await nuiCall('notifications:save', {
imei,
payload: { items, version: 1 },
})
if (!response.success) {
console.error(
`[Phone notifications] Could not persist notifications for ${imei}: ${response.error ?? 'request_failed'}`,
)
}
})
const tracked = queued.finally(() => {
if (persistenceQueues.get(imei) === tracked)
persistenceQueues.delete(imei)
})
persistenceQueues.set(imei, tracked)
}
function hydrate(payload: unknown, imei: string): void {
const stored: PhoneNotification[] = []
if (payload !== undefined && payload !== null) {
if (
typeof payload !== 'object' ||
(payload as Partial<PersistedNotificationsV1>).version !== 1 ||
!Array.isArray((payload as Partial<PersistedNotificationsV1>).items)
) {
console.error('[Phone notifications] Invalid persisted notification data.')
} else {
for (const item of (payload as PersistedNotificationsV1).items) {
if (
typeof item?.id !== 'string' ||
typeof item?.appId !== 'string' ||
!isPhoneAppId(item.appId) ||
typeof item?.text !== 'string' ||
typeof item?.title !== 'string' ||
(item.subtitle !== undefined && typeof item.subtitle !== 'string')
) {
console.error('[Phone notifications] Ignored an invalid persisted notification.')
continue
}
stored.push({
appId: item.appId,
id: item.id,
...(item.subtitle ? { subtitle: item.subtitle } : {}),
text: item.text,
title: item.title,
})
}
}
}
const merged = new Map<string, PhoneNotification>()
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()]
persist(imei)
}
function deactivate(id: string): void {
const timeout = timeoutHandles.get(id)
if (timeout) clearTimeout(timeout)
timeoutHandles.delete(id)
stopToneHandles.get(id)?.()
stopToneHandles.delete(id)
}
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
persist(imei)
}
function activate(notification: PhoneNotification): void { function activate(notification: PhoneNotification): void {
const preferences = notification.device?.preferences ?? phone.preferences const preferences = notification.device?.preferences ?? phone.preferences
const appPreferences = const appPreferences =
@@ -86,11 +192,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
(notification) => notification.id === id, (notification) => notification.id === id,
) )
if (index < 0 && deviceIndex < 0) return if (index < 0 && deviceIndex < 0) return
const timeout = timeoutHandles.get(id) deactivate(id)
if (timeout) clearTimeout(timeout)
timeoutHandles.delete(id)
stopToneHandles.get(id)?.()
stopToneHandles.delete(id)
if (index >= 0) { if (index >= 0) {
const wasCurrent = index === 0 const wasCurrent = index === 0
@@ -116,6 +218,35 @@ export const useNotificationsStore = defineStore('notifications', () => {
if (current.value) dismiss(current.value.id) if (current.value) dismiss(current.value.id)
} }
function dismissFromLockScreen(id: string): void {
dismiss(id)
const imei = phone.device?.imei
if (!imei) return
const notifications = lockScreenQueues.value[imei]
if (!notifications) return
const index = notifications.findIndex((notification) => notification.id === id)
if (index >= 0) notifications.splice(index, 1)
persist(imei)
}
function clearLockScreen(): void {
const imei = phone.device?.imei
if (!imei) return
for (const notification of [...(lockScreenQueues.value[imei] ?? [])])
dismiss(notification.id)
lockScreenQueues.value[imei] = []
persist(imei)
}
function hideDevicePreview(imei: string): void {
for (let index = deviceQueue.value.length - 1; index >= 0; index -= 1) {
const notification = deviceQueue.value[index]
if (notification.device?.imei !== imei) continue
deactivate(notification.id)
deviceQueue.value.splice(index, 1)
}
}
function show(input: PhoneNotificationInput): string | null { function show(input: PhoneNotificationInput): string | null {
const preferences = input.device?.preferences ?? phone.preferences const preferences = input.device?.preferences ?? phone.preferences
const appPreferences = preferences.settings.notifications[input.appId] const appPreferences = preferences.settings.notifications[input.appId]
@@ -139,6 +270,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
...input, ...input,
id: `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, id: `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
} }
remember(notification)
if (notification.device) { if (notification.device) {
const isFirstForDevice = !deviceQueue.value.some( const isFirstForDevice = !deviceQueue.value.some(
(pending) => pending.device?.imei === notification.device?.imei, (pending) => pending.device?.imei === notification.device?.imei,
@@ -154,10 +286,15 @@ export const useNotificationsStore = defineStore('notifications', () => {
return { return {
current, current,
clearLockScreen,
devicePreviews, devicePreviews,
dismiss, dismiss,
dismissCurrent, dismissCurrent,
dismissFromLockScreen,
hideDevicePreview,
hydrate,
isPeeking, isPeeking,
lockScreenNotifications,
queue, queue,
requiresAttention, requiresAttention,
show, show,
+5
View File
@@ -19,6 +19,11 @@ Config.Phone = {
DeviceName = "iFruit Phone", DeviceName = "iFruit Phone",
} }
Config.NotificationTest = {
Command = "phonenotifytest",
AdminGroups = { "admin" },
}
Config.Security = { Config.Security = {
PasscodePepperConvar = "sky_phone_passcode_pepper", PasscodePepperConvar = "sky_phone_passcode_pepper",
MaximumAttempts = 5, MaximumAttempts = 5,
+1 -1
View File
@@ -42,7 +42,7 @@ Locales["en"] = {
open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions", open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions",
timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi", timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi",
}, },
Notifications = { now = "now" }, Notifications = { clearAll = "Clear All", now = "now" },
LockScreen = { LockScreen = {
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open", label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
passcode = { passcode = {
+1
View File
@@ -53,6 +53,7 @@ server_scripts {
'source/bridge/server/inventory/*.lua', 'source/bridge/server/inventory/*.lua',
'source/server/db_migrate.lua', 'source/server/db_migrate.lua',
'source/server/phone.lua', 'source/server/phone.lua',
'source/server/debug.lua',
'source/server/sim.lua', 'source/server/sim.lua',
'source/server/calls.lua', 'source/server/calls.lua',
'source/server/media.lua', 'source/server/media.lua',
+5
View File
@@ -13,6 +13,7 @@ local server_callbacks = {
"security:change-passcode", "security:change-passcode",
"security:disable-passcode", "security:disable-passcode",
"device:save", "device:save",
"notifications:save",
"device:factory-reset", "device:factory-reset",
"account:login", "account:login",
"account:register", "account:register",
@@ -616,6 +617,10 @@ RegisterNetEvent("sky_phone:billing:new", function(data)
SendNUIMessage({ type = "billing:new", data = data }) SendNUIMessage({ type = "billing:new", data = data })
end) end)
RegisterNetEvent("sky_phone:notification:test", function(data)
SendNUIMessage({ type = "notification:show", data = data })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data) RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data }) SendNUIMessage({ type = "messages:changed", data = data })
end) end)
+78
View File
@@ -0,0 +1,78 @@
Bridge.Database.AfterMigration("sky_phone", function()
RegisterCommand(Config.NotificationTest.Command, function(source)
if source == 0 then
print(("[sky_phone] /%s must be used by an in-game admin."):format(Config.NotificationTest.Command))
return
end
if not Bridge.Framework.HasAdminGroup(source, Config.NotificationTest.AdminGroups) then
Bridge.Debug(
"warn",
"[sky_phone] Source %s attempted to use the notification test command without an admin group.",
tostring(source)
)
return
end
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
local slot = slots[1]
if not slot then
Bridge.Debug(
"warn",
"[sky_phone] Notification test failed because source %s has no phone item.",
tostring(source)
)
TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing")
return
end
local imei, device_error = SkyPhone.EnsureDevice(source, slot)
if not imei then
Bridge.Debug(
"warn",
"[sky_phone] Notification test could not resolve a device for source %s: %s.",
tostring(source),
tostring(device_error)
)
TriggerClientEvent("sky_phone:device:error", source, device_error or "request_failed")
return
end
local device = SkyPhone.LoadDevice(imei)
if not device then
Bridge.Debug(
"error",
"[sky_phone] Notification test could not load device %s for source %s.",
imei,
tostring(source)
)
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
return
end
local settings = Bridge.Database.Query([[
SELECT `payload`
FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'settings'
LIMIT 1
]], { imei })[1]
TriggerClientEvent("sky_phone:notification:test", source, {
appId = "messages",
title = "Notification Test",
text = "This is a test notification.",
device = {
imei = imei,
name = device.device_name,
settings = settings and settings.payload or nil,
},
})
Bridge.Debug(
"info",
"[sky_phone] Sent a test notification to source %s on device %s.",
tostring(source),
imei
)
end, false)
end)
+54
View File
@@ -947,6 +947,60 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
return { success = true, data = { revision = 1 } } return { success = true, data = { revision = 1 } }
end) end)
Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
if not SkyPhone.AllowOperation(source, "notifications_save", 240, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or type(data.payload) ~= "table" then
return { success = false, error = "invalid_request" }
end
if #SkyPhone.FindDeviceSlots(source, data.imei) == 0 then
Bridge.Debug(
"warn",
"[sky_phone] Source %s attempted to save notifications for unowned device %s.",
tostring(source),
tostring(data.imei)
)
return { success = false, error = "device_not_owned" }
end
if data.payload.version ~= 1 or type(data.payload.items) ~= "table" then
return { success = false, error = "invalid_request" }
end
local item_count = #data.payload.items
local key_count = 0
for key in pairs(data.payload.items) do
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > item_count then
return { success = false, error = "invalid_request" }
end
key_count = key_count + 1
end
if key_count ~= item_count then
return { success = false, error = "invalid_request" }
end
for _, item in ipairs(data.payload.items) do
if type(item) ~= "table"
or type(item.appId) ~= "string" or #item.appId > 64
or type(item.id) ~= "string" or #item.id > 100
or type(item.title) ~= "string" or #item.title > 200
or type(item.text) ~= "string" or #item.text > 2000
or (item.subtitle ~= nil and (type(item.subtitle) ~= "string" or #item.subtitle > 200))
then
return { success = false, error = "invalid_request" }
end
end
local encoded = json.encode(data.payload)
if #encoded > max_device_data_bytes then
return { success = false, error = "payload_too_large" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, 'notifications', ?)
ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1
]], { data.imei, encoded })
return { success = true }
end)
for _, endpoint in ipairs({ "account:login", "mail:login" }) do for _, endpoint in ipairs({ "account:login", "mail:login" }) do
Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source, data) Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source, data)
return authenticate(source, data, false) return authenticate(source, data, false)