mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 02:01:40 +00:00
ADD - persist lock screen notifications
This commit is contained in:
+42
-2
@@ -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>
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 } }
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user