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
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
device?: PhoneNotificationDevicePayload
}
type MailEventData = {
counts?: MailCounts
device?: PhoneNotificationDevicePayload
@@ -265,6 +269,13 @@ function getViewportScale(): number {
function hydratePhone(payload: PhoneOpenPayload): void {
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)
notes.hydrate(payload.notes ?? [])
clock.hydrate(payload.device?.data.alarms?.payload)
@@ -336,7 +347,20 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'app:resume') {
activitySuspended.value = false
} 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) {
const data = event.data.data as MailEventData
if (data.counts) mail.setCounts(data.counts)
@@ -749,6 +773,17 @@ function toggleControlCenter(): void {
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 {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
@@ -975,7 +1010,9 @@ onBeforeUnmount(() => {
<PhoneStatusBar
v-if="!isLocked"
:control-center-opened="controlCenterOpened"
lockable
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<SpringboardView />
<RouterView v-slot="{ Component }">
@@ -995,7 +1032,10 @@ onBeforeUnmount(() => {
<Transition name="lock-screen">
<PhoneLockScreen
v-if="isLocked"
:notifications="notifications.lockScreenNotifications"
@camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="notifications.dismissFromLockScreen"
@unlock="unlockPhone"
/>
</Transition>
@@ -1020,7 +1060,7 @@ onBeforeUnmount(() => {
/>
</Transition>
<PhoneNotifications
:notification="notifications.current"
:notification="phone.isOpen ? null : notifications.current"
@close="notifications.dismissCurrent()"
/>
<div class="phone-display-dimmer" aria-hidden="true"></div>
+140 -6
View File
@@ -268,10 +268,6 @@ button {
transform: translateY(calc(100% - 190px));
transform-origin: right bottom;
}
.notification-phone-background {
position: absolute;
inset: 0;
}
.phone-device {
position: relative;
width: 390px;
@@ -458,6 +454,19 @@ button {
width: calc(50% - 70px);
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 {
position: absolute;
top: 0;
@@ -569,7 +578,7 @@ button {
left: 30px;
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
min-height: 26px;
filter: drop-shadow(0 1px 2px #0009);
}
@@ -615,9 +624,123 @@ button {
0 2px 16px #0006;
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 {
position: absolute;
z-index: 1;
z-index: 3;
right: 0;
bottom: 25px;
left: 0;
@@ -656,6 +779,16 @@ button {
transform: rotate(45deg);
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 {
transition:
transform 0.64s cubic-bezier(0.32, 0.72, 0, 1),
@@ -694,6 +827,7 @@ button {
.lock-screen__swipe-chevron {
animation: none;
}
.lock-screen.lock-screen-enter-active,
.lock-screen-leave-active {
transition-duration: 0.18s;
}
@@ -2,8 +2,8 @@
import { kApp } from 'konsta/vue'
import { computed, type CSSProperties } from 'vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
@@ -61,11 +61,11 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
>
<div
class="notification-phone-background"
:class="`wallpaper--${preferences.settings.wallpaper}`"
></div>
<PhoneStatusBar />
<PhoneLockScreen
:notifications="[]"
:preferences="preferences"
preview
/>
<PhoneNotifications
:notification="notification"
@close="emit('close')"
+81 -9
View File
@@ -1,18 +1,29 @@
<script setup lang="ts">
import { kFab } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole } from 'lucide-vue-next'
import { kFab, kGlass } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { getPhoneApp } from '@/config/apps'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
const props = defineProps<{
notifications: PhoneNotification[]
preferences?: PhonePreferencesV1
preview?: boolean
}>()
const emit = defineEmits<{
camera: []
clearNotifications: []
dismissNotification: [id: string]
unlock: []
}>()
const phone = usePhoneStore()
const preferences = computed(() => props.preferences ?? phone.preferences)
const now = ref(new Date())
const dragOffset = ref(0)
const dragging = ref(false)
@@ -59,7 +70,13 @@ const flashlightShortcutColors = computed(() =>
)
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
pointerStartedAt = Date.now()
dragging.value = true
@@ -87,11 +104,18 @@ function finishPointer(event: PointerEvent): void {
}
function unlockWithKeyboard(event: KeyboardEvent): void {
if (props.preview) return
if (event.key === 'Enter' || event.key === ' ') emit('unlock')
}
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')
}
@@ -119,12 +143,15 @@ onBeforeUnmount(() => {
<section
class="lock-screen"
:class="[
`wallpaper--${phone.preferences.settings.wallpaper}`,
{ 'lock-screen--dragging': dragging },
`wallpaper--${preferences.settings.wallpaper}`,
{
'lock-screen--dragging': dragging,
'lock-screen--preview': preview,
},
]"
:style="dragStyle"
:aria-label="phone.t('LockScreen.label')"
tabindex="0"
:tabindex="preview ? -1 : 0"
@keydown="unlockWithKeyboard"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@@ -135,21 +162,66 @@ onBeforeUnmount(() => {
<div class="lock-screen__shade" aria-hidden="true"></div>
<header class="lock-screen__status">
<time class="lock-screen__status-time">{{ time }}</time>
<PhoneStatusIndicators class="lock-screen__indicators" />
</header>
<LockKeyhole
class="lock-screen__lock"
:size="14"
:stroke-width="1.8"
aria-hidden="true"
/>
<div class="lock-screen__content">
<time class="lock-screen__date">{{ date }}</time>
<time class="lock-screen__time">{{ time }}</time>
</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">
<k-fab
component="button"
+18 -5
View File
@@ -4,10 +4,14 @@ import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
withDefaults(defineProps<{ controlCenterOpened?: boolean }>(), {
controlCenterOpened: false,
})
const emit = defineEmits<{ controlCenter: [] }>()
withDefaults(
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
{
controlCenterOpened: false,
lockable: false,
},
)
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
const time = ref('')
let intervalId: number | undefined
@@ -31,7 +35,16 @@ onBeforeUnmount(() => {
<template>
<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
class="phone-status-bar__indicators"
type="button"
+131 -1
View File
@@ -6,6 +6,7 @@ import {
type PhoneNotificationDevice,
} from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
type PhonePreferencesV1,
@@ -15,7 +16,7 @@ vi.mock('@/utils/tones', () => ({
playPhoneTone: vi.fn(() => vi.fn()),
}))
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
nuiCall: vi.fn(async () => ({ success: true, data: { revision: 1 } })),
}))
function device(
@@ -27,6 +28,17 @@ function device(
return { imei, name: `Phone ${imei}`, preferences }
}
function openPhone(imei: string): void {
usePhoneStore().open({
device: {
data: {},
imei,
name: `Phone ${imei}`,
sim: null,
},
})
}
describe('notifications store', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -34,6 +46,7 @@ describe('notifications store', () => {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
vi.mocked(nuiCall).mockClear()
})
afterEach(() => {
@@ -127,4 +140,121 @@ describe('notifications store', () => {
expect(criticalId).not.toBeNull()
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 { defineStore } from 'pinia'
import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
@@ -27,13 +29,25 @@ export type PhoneNotification = PhoneNotificationInput & {
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 stopToneHandles = new Map<string, () => void>()
const persistenceQueues = new Map<string, Promise<void>>()
export const useNotificationsStore = defineStore('notifications', () => {
const phone = usePhoneStore()
const queue = ref<PhoneNotification[]>([])
const deviceQueue = ref<PhoneNotification[]>([])
const lockScreenQueues = ref<Record<string, PhoneNotification[]>>({})
const current = computed(() => queue.value[0] ?? null)
const devicePreviews = computed(() => {
const devices = new Set<string>()
@@ -45,6 +59,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
})
})
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(
() =>
!phone.isOpen &&
@@ -52,6 +71,93 @@ export const useNotificationsStore = defineStore('notifications', () => {
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 {
const preferences = notification.device?.preferences ?? phone.preferences
const appPreferences =
@@ -86,11 +192,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
(notification) => notification.id === id,
)
if (index < 0 && deviceIndex < 0) return
const timeout = timeoutHandles.get(id)
if (timeout) clearTimeout(timeout)
timeoutHandles.delete(id)
stopToneHandles.get(id)?.()
stopToneHandles.delete(id)
deactivate(id)
if (index >= 0) {
const wasCurrent = index === 0
@@ -116,6 +218,35 @@ export const useNotificationsStore = defineStore('notifications', () => {
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 {
const preferences = input.device?.preferences ?? phone.preferences
const appPreferences = preferences.settings.notifications[input.appId]
@@ -139,6 +270,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
...input,
id: `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
}
remember(notification)
if (notification.device) {
const isFirstForDevice = !deviceQueue.value.some(
(pending) => pending.device?.imei === notification.device?.imei,
@@ -154,10 +286,15 @@ export const useNotificationsStore = defineStore('notifications', () => {
return {
current,
clearLockScreen,
devicePreviews,
dismiss,
dismissCurrent,
dismissFromLockScreen,
hideDevicePreview,
hydrate,
isPeeking,
lockScreenNotifications,
queue,
requiresAttention,
show,