fix(web): track 'notification seen' state across tabs & page loads (#1121)

**New Features**
	- Enhanced notifications tracking that updates seen status in real time.
	- Improved notification indicators provide a more consistent and responsive experience.
	- Persistent state management ensures your viewed notifications remain accurately reflected across sessions.
	- New composable functions introduced for better management of notification visibility and interaction.
	- Streamlined notification handling by simplifying state management processes.
This commit is contained in:
Pujit Mehrotra
2025-02-06 12:00:53 -05:00
committed by GitHub
parent a21f39d617
commit 321703e907
4 changed files with 124 additions and 24 deletions

View File

@@ -3,7 +3,7 @@ import { BellIcon, ExclamationTriangleIcon, ShieldExclamationIcon } from '@heroi
import { cn } from '~/components/shadcn/utils';
import { Importance, type OverviewQuery } from '~/composables/gql/graphql';
const props = defineProps<{ overview?: OverviewQuery['notifications']['overview'], seen?: boolean }>();
const props = defineProps<{ overview?: OverviewQuery['notifications']['overview']; seen?: boolean }>();
const indicatorLevel = computed(() => {
if (!props.overview?.unread) {
@@ -51,4 +51,4 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
:class="cn('absolute -top-1 -right-1 size-4 rounded-full', icon.color)"
/>
</div>
</template>
</template>

View File

@@ -2,6 +2,7 @@
import { CheckIcon } from '@heroicons/vue/24/solid';
import { useQuery } from '@vue/apollo-composable';
import { vInfiniteScroll } from '@vueuse/components';
import { useHaveSeenNotifications } from '~/composables/api/use-notifications';
import { useFragment } from '~/composables/gql/fragment-masking';
import type { Importance, NotificationType } from '~/composables/gql/graphql';
import { useUnraidApiStore } from '~/store/unraidApi';
@@ -47,6 +48,21 @@ const notifications = computed(() => {
return list.filter((n) => n.type === props.type);
});
// saves timestamp of latest visible notification to local storage
const { latestSeenTimestamp } = useHaveSeenNotifications();
watch(
notifications,
() => {
const [latest] = notifications.value;
if (!latest?.timestamp) return;
if (new Date(latest.timestamp) > new Date(latestSeenTimestamp.value)) {
console.log('[notif list] setting last seen timestamp', latest.timestamp);
latestSeenTimestamp.value = latest.timestamp;
}
},
{ immediate: true }
);
async function onLoadMore() {
console.log('[getNotifications] onLoadMore');
const incoming = await fetchMore({

View File

@@ -2,6 +2,7 @@
import { Button } from '@/components/shadcn/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
import { useFragment } from '~/composables/gql';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
import { Importance, NotificationType } from '~/composables/gql/graphql';
@@ -37,12 +38,17 @@ const confirmAndDeleteArchives = async () => {
const { result } = useQuery(notificationsOverview, null, {
pollInterval: 2_000, // 2 seconds
});
const { latestNotificationTimestamp, haveSeenNotifications } = useTrackLatestSeenNotification();
const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubscription);
onNotificationAdded(({ data }) => {
if (!data) return;
const notif = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
if (notif.type !== NotificationType.Unread) return;
if (notif.timestamp) {
latestNotificationTimestamp.value = notif.timestamp;
}
// probably smart to leave this log outside the if-block for the initial release
console.log('incoming notification', notif);
if (!globalThis.toast) {
@@ -78,32 +84,13 @@ const readArchivedCount = computed(() => {
const { archive, unread } = overview.value;
return Math.max(0, archive.total - unread.total);
});
/** whether user has viewed their notifications */
const hasSeenNotifications = ref(false);
// renews unseen state when new notifications arrive
watch(
() => overview.value?.unread,
(newVal, oldVal) => {
if (!newVal || !oldVal) return;
if (newVal.total > oldVal.total) {
hasSeenNotifications.value = false;
}
}
);
const prepareToViewNotifications = () => {
determineTeleportTarget();
hasSeenNotifications.value = true;
};
</script>
<template>
<Sheet>
<SheetTrigger @click="prepareToViewNotifications">
<SheetTrigger @click="determineTeleportTarget">
<span class="sr-only">Notifications</span>
<NotificationsIndicator :overview="overview" :seen="hasSeenNotifications" />
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
</SheetTrigger>
<SheetContent
:to="teleportTarget"

View File

@@ -0,0 +1,97 @@
import { useQuery } from '@vue/apollo-composable';
import { useStorage } from '@vueuse/core';
import {
getNotifications,
NOTIFICATION_FRAGMENT,
} from '~/components/Notifications/graphql/notification.query';
import { useFragment } from '~/composables/gql/fragment-masking';
import { NotificationType } from '../gql/graphql';
const LATEST_SEEN_TIMESTAMP_KEY = 'latest-seen-notification-timestamp';
const HAVE_SEEN_NOTIFICATIONS_KEY = 'have-seen-notifications';
/**
* Composable for managing user's state of having seen notifications.
*
* Returns reactive references to two local-storage values:
* - `latestSeenTimestamp`: timestamp of the latest notification that has been viewed.
* - `haveSeenNotifications`: a boolean indicating whether the user has seen their notifications.
*
* Both properties are reactive refs and updating them will persist to local storage.
*
* `haveSeenNotifications` is considered derived-state and should not be modified directly, outside of
* related composables. Instead, update `latestSeenTimestamp` to affect global state.
*/
export function useHaveSeenNotifications() {
return {
/**
* Local-storage Timestamp of the latest notification that has been viewed.
* It should be modified externally, when user views their notifications.
*
* Writing this ref will persist to local storage and affect global state.
*/
latestSeenTimestamp: useStorage(LATEST_SEEN_TIMESTAMP_KEY, new Date(0).toISOString()),
/**
* Local-storage global state of whether a user has seen their notifications.
* Consider this derived-state and avoid modifying this directly, outside of
* related composables.
*
* Writing this ref will persist to local storage and affect global state.
*/
haveSeenNotifications: useStorage<boolean>(HAVE_SEEN_NOTIFICATIONS_KEY, null),
};
}
export function useTrackLatestSeenNotification() {
const { latestSeenTimestamp, haveSeenNotifications } = useHaveSeenNotifications();
const { result: latestNotifications } = useQuery(getNotifications, () => ({
filter: {
offset: 0,
limit: 1,
type: NotificationType.Unread,
},
}));
const latestNotification = computed(() => {
const list = latestNotifications.value?.notifications.list;
if (!list) return;
const [notification] = useFragment(NOTIFICATION_FRAGMENT, list);
return notification;
});
// initialize timestamp of latest notification
const latestNotificationTimestamp = ref<string | null>();
const stopLatestInit = watchOnce(latestNotification, () => {
latestNotificationTimestamp.value = latestNotification.value?.timestamp;
});
// prevent memory leak in edge case
onUnmounted(() => stopLatestInit());
const isBeforeLastSeen = (timestamp?: string | null) =>
new Date(timestamp ?? '0') <= new Date(latestSeenTimestamp.value);
// derive haveSeenNotifications by comparing last seen's timestamp to latest's timestamp
watchEffect(() => {
if (!latestNotificationTimestamp.value) {
return;
}
haveSeenNotifications.value = isBeforeLastSeen(latestNotificationTimestamp.value);
console.log('[use-notifications] set haveSeenNotifications to', haveSeenNotifications.value);
});
return {
/**
* In-memory timestamp of the latest notification in the system.
* Loaded automatically upon init, but not explicitly tracked.
*
* It is safe/expected to mutate this ref from other events, such as incoming notifications.
* This will cause re-computation of `haveSeenNotifications` state.
*/
latestNotificationTimestamp,
/**
* Derived state of whether a user has seen their notifications. Avoid mutating directly.
*
* Writing this ref will persist to local storage and affect global state.
*/
haveSeenNotifications,
};
}