mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
97
web/composables/api/use-notifications.ts
Normal file
97
web/composables/api/use-notifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user