Skip to content

Commit 64cf6ec

Browse files
authored
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.
1 parent 57526de commit 64cf6ec

File tree

4 files changed

+124
-24
lines changed

4 files changed

+124
-24
lines changed

web/components/Notifications/Indicator.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BellIcon, ExclamationTriangleIcon, ShieldExclamationIcon } from '@heroi
33
import { cn } from '~/components/shadcn/utils';
44
import { Importance, type OverviewQuery } from '~/composables/gql/graphql';
55
6-
const props = defineProps<{ overview?: OverviewQuery['notifications']['overview'], seen?: boolean }>();
6+
const props = defineProps<{ overview?: OverviewQuery['notifications']['overview']; seen?: boolean }>();
77
88
const indicatorLevel = computed(() => {
99
if (!props.overview?.unread) {
@@ -51,4 +51,4 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
5151
:class="cn('absolute -top-1 -right-1 size-4 rounded-full', icon.color)"
5252
/>
5353
</div>
54-
</template>
54+
</template>

web/components/Notifications/List.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { CheckIcon } from '@heroicons/vue/24/solid';
33
import { useQuery } from '@vue/apollo-composable';
44
import { vInfiniteScroll } from '@vueuse/components';
5+
import { useHaveSeenNotifications } from '~/composables/api/use-notifications';
56
import { useFragment } from '~/composables/gql/fragment-masking';
67
import type { Importance, NotificationType } from '~/composables/gql/graphql';
78
import { useUnraidApiStore } from '~/store/unraidApi';
@@ -47,6 +48,21 @@ const notifications = computed(() => {
4748
return list.filter((n) => n.type === props.type);
4849
});
4950
51+
// saves timestamp of latest visible notification to local storage
52+
const { latestSeenTimestamp } = useHaveSeenNotifications();
53+
watch(
54+
notifications,
55+
() => {
56+
const [latest] = notifications.value;
57+
if (!latest?.timestamp) return;
58+
if (new Date(latest.timestamp) > new Date(latestSeenTimestamp.value)) {
59+
console.log('[notif list] setting last seen timestamp', latest.timestamp);
60+
latestSeenTimestamp.value = latest.timestamp;
61+
}
62+
},
63+
{ immediate: true }
64+
);
65+
5066
async function onLoadMore() {
5167
console.log('[getNotifications] onLoadMore');
5268
const incoming = await fetchMore({

web/components/Notifications/Sidebar.vue

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Button } from '@/components/shadcn/button';
33
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
44
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
5+
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
56
import { useFragment } from '~/composables/gql';
67
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
78
import { Importance, NotificationType } from '~/composables/gql/graphql';
@@ -37,12 +38,17 @@ const confirmAndDeleteArchives = async () => {
3738
const { result } = useQuery(notificationsOverview, null, {
3839
pollInterval: 2_000, // 2 seconds
3940
});
40-
41+
const { latestNotificationTimestamp, haveSeenNotifications } = useTrackLatestSeenNotification();
4142
const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubscription);
43+
4244
onNotificationAdded(({ data }) => {
4345
if (!data) return;
4446
const notif = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
4547
if (notif.type !== NotificationType.Unread) return;
48+
49+
if (notif.timestamp) {
50+
latestNotificationTimestamp.value = notif.timestamp;
51+
}
4652
// probably smart to leave this log outside the if-block for the initial release
4753
console.log('incoming notification', notif);
4854
if (!globalThis.toast) {
@@ -78,32 +84,13 @@ const readArchivedCount = computed(() => {
7884
const { archive, unread } = overview.value;
7985
return Math.max(0, archive.total - unread.total);
8086
});
81-
82-
/** whether user has viewed their notifications */
83-
const hasSeenNotifications = ref(false);
84-
85-
// renews unseen state when new notifications arrive
86-
watch(
87-
() => overview.value?.unread,
88-
(newVal, oldVal) => {
89-
if (!newVal || !oldVal) return;
90-
if (newVal.total > oldVal.total) {
91-
hasSeenNotifications.value = false;
92-
}
93-
}
94-
);
95-
96-
const prepareToViewNotifications = () => {
97-
determineTeleportTarget();
98-
hasSeenNotifications.value = true;
99-
};
10087
</script>
10188

10289
<template>
10390
<Sheet>
104-
<SheetTrigger @click="prepareToViewNotifications">
91+
<SheetTrigger @click="determineTeleportTarget">
10592
<span class="sr-only">Notifications</span>
106-
<NotificationsIndicator :overview="overview" :seen="hasSeenNotifications" />
93+
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
10794
</SheetTrigger>
10895
<SheetContent
10996
:to="teleportTarget"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useQuery } from '@vue/apollo-composable';
2+
import { useStorage } from '@vueuse/core';
3+
import {
4+
getNotifications,
5+
NOTIFICATION_FRAGMENT,
6+
} from '~/components/Notifications/graphql/notification.query';
7+
import { useFragment } from '~/composables/gql/fragment-masking';
8+
import { NotificationType } from '../gql/graphql';
9+
10+
const LATEST_SEEN_TIMESTAMP_KEY = 'latest-seen-notification-timestamp';
11+
const HAVE_SEEN_NOTIFICATIONS_KEY = 'have-seen-notifications';
12+
13+
/**
14+
* Composable for managing user's state of having seen notifications.
15+
*
16+
* Returns reactive references to two local-storage values:
17+
* - `latestSeenTimestamp`: timestamp of the latest notification that has been viewed.
18+
* - `haveSeenNotifications`: a boolean indicating whether the user has seen their notifications.
19+
*
20+
* Both properties are reactive refs and updating them will persist to local storage.
21+
*
22+
* `haveSeenNotifications` is considered derived-state and should not be modified directly, outside of
23+
* related composables. Instead, update `latestSeenTimestamp` to affect global state.
24+
*/
25+
export function useHaveSeenNotifications() {
26+
return {
27+
/**
28+
* Local-storage Timestamp of the latest notification that has been viewed.
29+
* It should be modified externally, when user views their notifications.
30+
*
31+
* Writing this ref will persist to local storage and affect global state.
32+
*/
33+
latestSeenTimestamp: useStorage(LATEST_SEEN_TIMESTAMP_KEY, new Date(0).toISOString()),
34+
/**
35+
* Local-storage global state of whether a user has seen their notifications.
36+
* Consider this derived-state and avoid modifying this directly, outside of
37+
* related composables.
38+
*
39+
* Writing this ref will persist to local storage and affect global state.
40+
*/
41+
haveSeenNotifications: useStorage<boolean>(HAVE_SEEN_NOTIFICATIONS_KEY, null),
42+
};
43+
}
44+
45+
export function useTrackLatestSeenNotification() {
46+
const { latestSeenTimestamp, haveSeenNotifications } = useHaveSeenNotifications();
47+
const { result: latestNotifications } = useQuery(getNotifications, () => ({
48+
filter: {
49+
offset: 0,
50+
limit: 1,
51+
type: NotificationType.Unread,
52+
},
53+
}));
54+
const latestNotification = computed(() => {
55+
const list = latestNotifications.value?.notifications.list;
56+
if (!list) return;
57+
const [notification] = useFragment(NOTIFICATION_FRAGMENT, list);
58+
return notification;
59+
});
60+
61+
// initialize timestamp of latest notification
62+
const latestNotificationTimestamp = ref<string | null>();
63+
const stopLatestInit = watchOnce(latestNotification, () => {
64+
latestNotificationTimestamp.value = latestNotification.value?.timestamp;
65+
});
66+
// prevent memory leak in edge case
67+
onUnmounted(() => stopLatestInit());
68+
69+
const isBeforeLastSeen = (timestamp?: string | null) =>
70+
new Date(timestamp ?? '0') <= new Date(latestSeenTimestamp.value);
71+
72+
// derive haveSeenNotifications by comparing last seen's timestamp to latest's timestamp
73+
watchEffect(() => {
74+
if (!latestNotificationTimestamp.value) {
75+
return;
76+
}
77+
haveSeenNotifications.value = isBeforeLastSeen(latestNotificationTimestamp.value);
78+
console.log('[use-notifications] set haveSeenNotifications to', haveSeenNotifications.value);
79+
});
80+
81+
return {
82+
/**
83+
* In-memory timestamp of the latest notification in the system.
84+
* Loaded automatically upon init, but not explicitly tracked.
85+
*
86+
* It is safe/expected to mutate this ref from other events, such as incoming notifications.
87+
* This will cause re-computation of `haveSeenNotifications` state.
88+
*/
89+
latestNotificationTimestamp,
90+
/**
91+
* Derived state of whether a user has seen their notifications. Avoid mutating directly.
92+
*
93+
* Writing this ref will persist to local storage and affect global state.
94+
*/
95+
haveSeenNotifications,
96+
};
97+
}

0 commit comments

Comments
 (0)