mirror of
https://github.com/unraid/api.git
synced 2025-12-31 21:49:57 -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 { cn } from '~/components/shadcn/utils';
|
||||||
import { Importance, type OverviewQuery } from '~/composables/gql/graphql';
|
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(() => {
|
const indicatorLevel = computed(() => {
|
||||||
if (!props.overview?.unread) {
|
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)"
|
:class="cn('absolute -top-1 -right-1 size-4 rounded-full', icon.color)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { CheckIcon } from '@heroicons/vue/24/solid';
|
import { CheckIcon } from '@heroicons/vue/24/solid';
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import { vInfiniteScroll } from '@vueuse/components';
|
import { vInfiniteScroll } from '@vueuse/components';
|
||||||
|
import { useHaveSeenNotifications } from '~/composables/api/use-notifications';
|
||||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||||
import type { Importance, NotificationType } from '~/composables/gql/graphql';
|
import type { Importance, NotificationType } from '~/composables/gql/graphql';
|
||||||
import { useUnraidApiStore } from '~/store/unraidApi';
|
import { useUnraidApiStore } from '~/store/unraidApi';
|
||||||
@@ -47,6 +48,21 @@ const notifications = computed(() => {
|
|||||||
return list.filter((n) => n.type === props.type);
|
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() {
|
async function onLoadMore() {
|
||||||
console.log('[getNotifications] onLoadMore');
|
console.log('[getNotifications] onLoadMore');
|
||||||
const incoming = await fetchMore({
|
const incoming = await fetchMore({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Button } from '@/components/shadcn/button';
|
import { Button } from '@/components/shadcn/button';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
|
||||||
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
|
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
|
||||||
|
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
|
||||||
import { useFragment } from '~/composables/gql';
|
import { useFragment } from '~/composables/gql';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
|
||||||
import { Importance, NotificationType } from '~/composables/gql/graphql';
|
import { Importance, NotificationType } from '~/composables/gql/graphql';
|
||||||
@@ -37,12 +38,17 @@ const confirmAndDeleteArchives = async () => {
|
|||||||
const { result } = useQuery(notificationsOverview, null, {
|
const { result } = useQuery(notificationsOverview, null, {
|
||||||
pollInterval: 2_000, // 2 seconds
|
pollInterval: 2_000, // 2 seconds
|
||||||
});
|
});
|
||||||
|
const { latestNotificationTimestamp, haveSeenNotifications } = useTrackLatestSeenNotification();
|
||||||
const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubscription);
|
const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubscription);
|
||||||
|
|
||||||
onNotificationAdded(({ data }) => {
|
onNotificationAdded(({ data }) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const notif = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
|
const notif = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
|
||||||
if (notif.type !== NotificationType.Unread) return;
|
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
|
// probably smart to leave this log outside the if-block for the initial release
|
||||||
console.log('incoming notification', notif);
|
console.log('incoming notification', notif);
|
||||||
if (!globalThis.toast) {
|
if (!globalThis.toast) {
|
||||||
@@ -78,32 +84,13 @@ const readArchivedCount = computed(() => {
|
|||||||
const { archive, unread } = overview.value;
|
const { archive, unread } = overview.value;
|
||||||
return Math.max(0, archive.total - unread.total);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger @click="prepareToViewNotifications">
|
<SheetTrigger @click="determineTeleportTarget">
|
||||||
<span class="sr-only">Notifications</span>
|
<span class="sr-only">Notifications</span>
|
||||||
<NotificationsIndicator :overview="overview" :seen="hasSeenNotifications" />
|
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
:to="teleportTarget"
|
: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