mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - persist lock screen notifications
This commit is contained in:
+42
-2
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,11 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
Config.NotificationTest = {
|
||||
Command = "phonenotifytest",
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Security = {
|
||||
PasscodePepperConvar = "sky_phone_passcode_pepper",
|
||||
MaximumAttempts = 5,
|
||||
|
||||
@@ -42,7 +42,7 @@ Locales["en"] = {
|
||||
open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions",
|
||||
timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi",
|
||||
},
|
||||
Notifications = { now = "now" },
|
||||
Notifications = { clearAll = "Clear All", now = "now" },
|
||||
LockScreen = {
|
||||
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
|
||||
passcode = {
|
||||
|
||||
@@ -53,6 +53,7 @@ server_scripts {
|
||||
'source/bridge/server/inventory/*.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/debug.lua',
|
||||
'source/server/sim.lua',
|
||||
'source/server/calls.lua',
|
||||
'source/server/media.lua',
|
||||
|
||||
@@ -13,6 +13,7 @@ local server_callbacks = {
|
||||
"security:change-passcode",
|
||||
"security:disable-passcode",
|
||||
"device:save",
|
||||
"notifications:save",
|
||||
"device:factory-reset",
|
||||
"account:login",
|
||||
"account:register",
|
||||
@@ -616,6 +617,10 @@ RegisterNetEvent("sky_phone:billing:new", function(data)
|
||||
SendNUIMessage({ type = "billing:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:notification:test", function(data)
|
||||
SendNUIMessage({ type = "notification:show", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:messages:changed", function(data)
|
||||
SendNUIMessage({ type = "messages:changed", data = data })
|
||||
end)
|
||||
|
||||
@@ -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)
|
||||
@@ -947,6 +947,60 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
|
||||
return { success = true, data = { revision = 1 } }
|
||||
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
|
||||
Bridge.Callbacks.Register("sky_phone:" .. endpoint, function(source, data)
|
||||
return authenticate(source, data, false)
|
||||
|
||||
Reference in New Issue
Block a user