ENH - add swipe-to-clear lock-screen notifications

This commit is contained in:
DerEchteAlec
2026-08-13 03:10:40 +02:00
parent 0c702b4c6c
commit a98b3b0c98
4 changed files with 317 additions and 52 deletions
+51 -18
View File
@@ -734,17 +734,65 @@ button {
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.lock-screen__notification-row {
position: relative;
overflow: hidden;
min-height: 76px;
border-radius: 18px;
background: transparent;
isolation: isolate;
}
.lock-screen__notification-clear {
position: absolute;
z-index: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
width: 76px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: 0;
padding: 0;
color: #fff;
background: #ff3b30;
font-size: 10px;
font-weight: 650;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease-out;
}
.lock-screen__notification-row.is-action-visible {
background: #ff3b30;
}
.lock-screen__notification-row.is-action-visible
.lock-screen__notification-clear {
opacity: 1;
}
.lock-screen__notification {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
min-height: 72px;
width: 100%;
min-height: 76px;
border-radius: 18px;
padding: 11px 38px 11px 11px;
padding: 11px;
color: #fff;
background-color: rgb(28 28 32 / 72%);
background-color: rgb(38 36 40 / 76%);
text-shadow: 0 1px 3px #0009;
touch-action: pan-y;
transform: translate3d(var(--notification-swipe), 0, 0);
transition: transform 0.28s cubic-bezier(0.22, 0.78, 0.24, 1);
will-change: transform;
backdrop-filter: blur(22px) saturate(1.18);
-webkit-backdrop-filter: blur(22px) saturate(1.18);
}
.lock-screen__notification-row.is-swiping .lock-screen__notification {
transition: none;
}
.phone-notification.is-actionable {
cursor: pointer;
@@ -800,21 +848,6 @@ button {
-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: 3;
+189 -34
View File
@@ -1,12 +1,19 @@
<script setup lang="ts">
import { kFab, kGlass } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
import { Camera, Flashlight, LockKeyhole, Trash2 } 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 {
clampNotificationSwipeOffset,
NOTIFICATION_SWIPE_ACTION_WIDTH,
resolveNotificationSwipeAxis,
shouldRevealNotificationAction,
type NotificationSwipeAxis,
} from '@/utils/notificationSwipe'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
@@ -29,6 +36,9 @@ const now = ref(new Date())
const dragOffset = ref(0)
const dragging = ref(false)
const flashlightActive = ref(false)
const revealedNotificationId = ref<string | null>(null)
const swipingNotificationId = ref<string | null>(null)
const notificationSwipeOffset = ref(0)
const shortcutColors = {
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
@@ -37,6 +47,12 @@ const shortcutColors = {
let pointerStart = 0
let pointerStartedAt = 0
let clockTicker: number | undefined
let notificationPointerId: number | null = null
let notificationPointerStartX = 0
let notificationPointerStartY = 0
let notificationPointerStartedAt = 0
let notificationSwipeAxis: NotificationSwipeAxis | null = null
let suppressNotificationClickUntil = 0
const date = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
@@ -83,9 +99,130 @@ function onPointerDown(event: PointerEvent): void {
}
function openNotification(notification: PhoneNotification): void {
if (performance.now() <= suppressNotificationClickUntil) return
if (revealedNotificationId.value === notification.id) {
closeNotificationAction()
return
}
if (notification.route) emit('openNotification', notification)
}
function notificationOffset(notificationId: string): number {
if (swipingNotificationId.value === notificationId)
return notificationSwipeOffset.value
return revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
}
function notificationStyle(notificationId: string): Record<string, string> {
return {
'--notification-swipe': `${notificationOffset(notificationId)}px`,
}
}
function closeNotificationAction(): void {
revealedNotificationId.value = null
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
}
function beginNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (props.preview || event.button !== 0) return
if (
revealedNotificationId.value &&
revealedNotificationId.value !== notificationId
) {
revealedNotificationId.value = null
}
notificationPointerId = event.pointerId
notificationPointerStartX = event.clientX
notificationPointerStartY = event.clientY
notificationPointerStartedAt = performance.now()
notificationSwipeAxis = null
swipingNotificationId.value = notificationId
notificationSwipeOffset.value =
revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
}
function moveNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (
swipingNotificationId.value !== notificationId ||
notificationPointerId !== event.pointerId
) {
return
}
const deltaX = event.clientX - notificationPointerStartX
const deltaY = event.clientY - notificationPointerStartY
notificationSwipeAxis ??= resolveNotificationSwipeAxis(deltaX, deltaY)
if (notificationSwipeAxis === 'vertical') {
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
return
}
if (notificationSwipeAxis !== 'horizontal') return
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId))
target.setPointerCapture(event.pointerId)
event.preventDefault()
const startingOffset =
revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
notificationSwipeOffset.value = clampNotificationSwipeOffset(
startingOffset + deltaX,
)
}
function finishNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (
swipingNotificationId.value !== notificationId ||
notificationPointerId !== event.pointerId
) {
return
}
const elapsed = Math.max(1, performance.now() - notificationPointerStartedAt)
const velocityX = (event.clientX - notificationPointerStartX) / elapsed
const wasHorizontal = notificationSwipeAxis === 'horizontal'
if (wasHorizontal) {
suppressNotificationClickUntil = performance.now() + 120
revealedNotificationId.value = shouldRevealNotificationAction(
notificationSwipeOffset.value,
velocityX,
)
? notificationId
: null
}
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
notificationPointerId = null
notificationSwipeAxis = null
}
function dismissNotification(notificationId: string): void {
closeNotificationAction()
emit('dismissNotification', notificationId)
}
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = Math.min(0, event.clientY - pointerStart)
@@ -193,46 +330,64 @@ onBeforeUnmount(() => {
>
{{ phone.t('Notifications.clearAll') }}
</button>
<k-glass
<div
v-for="notification in props.notifications"
:key="notification.id"
class="lock-screen__notification"
:class="{ 'is-actionable': !!notification.route }"
:highlight="false"
:role="notification.route ? 'button' : undefined"
:tabindex="notification.route ? 0 : undefined"
:aria-label="
notification.route ? phone.t('Notifications.open') : undefined
"
@click="openNotification(notification)"
@keydown.enter.prevent="openNotification(notification)"
@keydown.space.prevent="openNotification(notification)"
class="lock-screen__notification-row"
:class="{
'is-revealed': revealedNotificationId === notification.id,
'is-swiping': swipingNotificationId === notification.id,
'is-action-visible': notificationOffset(notification.id) < -0.5,
}"
:style="notificationStyle(notification.id)"
>
<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"
class="lock-screen__notification-clear"
type="button"
:aria-label="phone.t('Common.close')"
@click.stop="emit('dismissNotification', notification.id)"
:tabindex="revealedNotificationId === notification.id ? 0 : -1"
:aria-hidden="revealedNotificationId !== notification.id"
:aria-label="phone.t('Notifications.clear')"
@click.stop="dismissNotification(notification.id)"
>
<X :size="14" aria-hidden="true" />
<Trash2 :size="18" :stroke-width="1.8" aria-hidden="true" />
<span>{{ phone.t('Notifications.clear') }}</span>
</button>
</k-glass>
<k-glass
class="lock-screen__notification"
:class="{ 'is-actionable': !!notification.route }"
:highlight="false"
:role="notification.route ? 'button' : undefined"
:tabindex="notification.route ? 0 : undefined"
:aria-label="
notification.route ? phone.t('Notifications.open') : undefined
"
@click="openNotification(notification)"
@keydown.enter.prevent="openNotification(notification)"
@keydown.space.prevent="openNotification(notification)"
@keydown.delete.prevent="dismissNotification(notification.id)"
@pointerdown.stop="beginNotificationSwipe(notification.id, $event)"
@pointermove.stop="moveNotificationSwipe(notification.id, $event)"
@pointerup.stop="finishNotificationSwipe(notification.id, $event)"
@pointercancel.stop="finishNotificationSwipe(notification.id, $event)"
>
<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>
</k-glass>
</div>
</section>
<div v-if="!preview" class="lock-screen__footer">
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import {
clampNotificationSwipeOffset,
NOTIFICATION_SWIPE_ACTION_WIDTH,
resolveNotificationSwipeAxis,
shouldRevealNotificationAction,
} from '@/utils/notificationSwipe'
describe('lock-screen notification swipe gestures', () => {
it('waits for a clear horizontal or vertical direction', () => {
expect(resolveNotificationSwipeAxis(5, 2)).toBeNull()
expect(resolveNotificationSwipeAxis(-24, 5)).toBe('horizontal')
expect(resolveNotificationSwipeAxis(-10, 18)).toBe('vertical')
})
it('only allows a resisted swipe to the left', () => {
expect(clampNotificationSwipeOffset(40)).toBe(0)
expect(clampNotificationSwipeOffset(-60)).toBe(-60)
expect(clampNotificationSwipeOffset(-240)).toBeGreaterThan(
-NOTIFICATION_SWIPE_ACTION_WIDTH - 17,
)
})
it('reveals clear after enough distance or a deliberate flick', () => {
expect(shouldRevealNotificationAction(-41, -0.2)).toBe(false)
expect(shouldRevealNotificationAction(-42, -0.2)).toBe(true)
expect(shouldRevealNotificationAction(-20, -0.5)).toBe(true)
expect(shouldRevealNotificationAction(-12, -0.8)).toBe(false)
})
})
+46
View File
@@ -0,0 +1,46 @@
export type NotificationSwipeAxis = 'horizontal' | 'vertical'
export const NOTIFICATION_SWIPE_ACTION_WIDTH = 76
export const NOTIFICATION_SWIPE_AXIS_LOCK = 8
export const NOTIFICATION_SWIPE_TRIGGER = 42
export function resolveNotificationSwipeAxis(
deltaX: number,
deltaY: number,
): NotificationSwipeAxis | null {
const horizontalDistance = Math.abs(deltaX)
const verticalDistance = Math.abs(deltaY)
if (
Math.max(horizontalDistance, verticalDistance) <
NOTIFICATION_SWIPE_AXIS_LOCK
) {
return null
}
return horizontalDistance > verticalDistance * 1.15
? 'horizontal'
: 'vertical'
}
export function clampNotificationSwipeOffset(deltaX: number): number {
if (deltaX >= 0) return 0
const distance = Math.abs(deltaX)
const resistedDistance =
distance <= NOTIFICATION_SWIPE_ACTION_WIDTH
? distance
: NOTIFICATION_SWIPE_ACTION_WIDTH +
(distance - NOTIFICATION_SWIPE_ACTION_WIDTH) * 0.16
return -Math.min(resistedDistance, NOTIFICATION_SWIPE_ACTION_WIDTH + 16)
}
export function shouldRevealNotificationAction(
offset: number,
velocityX: number,
): boolean {
return (
offset <= -NOTIFICATION_SWIPE_TRIGGER ||
(offset <= -18 && velocityX <= -0.45)
)
}