Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -529,14 +529,25 @@ export class NotificationsService {
* @returns An array of all notifications in the system.
*/
public async getNotifications(filters: NotificationFilter): Promise<Notification[]> {
this.logger.debug('Getting Notifications');
this.logger.verbose('Getting Notifications');

const { type = NotificationType.UNREAD } = filters;
const { ARCHIVE, UNREAD } = this.paths();
const directoryPath = filters.type === NotificationType.ARCHIVE ? ARCHIVE : UNREAD;

const unreadFiles = await this.listFilesInFolder(directoryPath);
const [notifications] = await this.loadNotificationsFromPaths(unreadFiles, filters);
let files: string[];

if (type === NotificationType.UNREAD) {
files = await this.listFilesInFolder(UNREAD);
} else {
// Exclude notifications present in both unread & archive from archive.
//* this is necessary because the legacy script writes new notifications to both places.
//* this should be a temporary measure.
const unreads = new Set(await readdir(UNREAD));
files = await this.listFilesInFolder(ARCHIVE, (archives) => {
return archives.filter((file) => !unreads.has(file));
});
}

const [notifications] = await this.loadNotificationsFromPaths(files, filters);
return notifications;
}

Expand All @@ -545,12 +556,16 @@ export class NotificationsService {
* Sorted latest-first by default.
*
* @param folderPath The path of the folder to read.
* @param narrowContent Returns which files from `folderPath` to include. Defaults to all.
* @param sortFn An optional function to sort folder contents. Defaults to descending birth time.
* @returns A list of absolute paths of all the files and contents in the folder.
*/
private async listFilesInFolder(folderPath: string, sortFn?: SortFn<Stats>): Promise<string[]> {
sortFn ??= (fileA, fileB) => fileB.birthtimeMs - fileA.birthtimeMs; // latest first
const contents = await readdir(folderPath);
private async listFilesInFolder(
folderPath: string,
narrowContent: (contents: string[]) => string[] = (contents) => contents,
sortFn: SortFn<Stats> = (fileA, fileB) => fileB.birthtimeMs - fileA.birthtimeMs // latest first
): Promise<string[]> {
const contents = narrowContent(await readdir(folderPath));
return contents
.map((content) => {
// pre-map each file's stats to avoid excess calls during sorting
Expand Down
2 changes: 1 addition & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ VITE_UNRAID_NET=https://unraid.ddev.site
VITE_OS_RELEASES="https://releases.unraid.net/os"
VITE_CALLBACK_KEY=aNotSoSecretKeyUsedToObfuscateQueryParams
VITE_ALLOW_CONSOLE_LOGS=true
VTIE_TAILWIND_BASE_FONT_SIZE=10
VITE_TAILWIND_BASE_FONT_SIZE=10
42 changes: 18 additions & 24 deletions web/components/Notifications/List.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
<script setup lang="ts">
import {
getNotifications,
NOTIFICATION_FRAGMENT,
} from "./graphql/notification.query";
import type { Importance, NotificationType } from "~/composables/gql/graphql";
import { useFragment } from "~/composables/gql/fragment-masking";
import { useQuery } from "@vue/apollo-composable";
import { vInfiniteScroll } from "@vueuse/components";
import { CheckIcon } from "@heroicons/vue/24/solid";
import { CheckIcon } from '@heroicons/vue/24/solid';
import { useQuery } from '@vue/apollo-composable';
import { vInfiniteScroll } from '@vueuse/components';
import { useFragment } from '~/composables/gql/fragment-masking';
import type { Importance, NotificationType } from '~/composables/gql/graphql';
import { getNotifications, NOTIFICATION_FRAGMENT } from './graphql/notification.query';

/**
* Page size is the max amount of items fetched from the api in a single request.
Expand All @@ -24,6 +21,7 @@ const props = withDefaults(
}
);

const canLoadMore = ref(true);
const { result, error, fetchMore } = useQuery(getNotifications, () => ({
filter: {
offset: 0,
Expand All @@ -34,23 +32,20 @@ const { result, error, fetchMore } = useQuery(getNotifications, () => ({
}));

watch(error, (newVal) => {
console.log("[getNotifications] error:", newVal);
console.log('[getNotifications] error:', newVal);
});

const notifications = computed(() => {
if (!result.value?.notifications.list) return [];
const list = useFragment(
NOTIFICATION_FRAGMENT,
result.value?.notifications.list
);
const list = useFragment(NOTIFICATION_FRAGMENT, result.value?.notifications.list);
// necessary because some items in this list may change their type (e.g. archival)
// and we don't want to display them in the wrong list client-side.
return list.filter((n) => n.type === props.type);
});

async function onLoadMore() {
console.log("[getNotifications] onLoadMore");
void fetchMore({
console.log('[getNotifications] onLoadMore');
const incoming = await fetchMore({
variables: {
filter: {
offset: notifications.value.length,
Expand All @@ -60,23 +55,22 @@ async function onLoadMore() {
},
},
});
const incomingCount = incoming?.data.notifications.list.length ?? 0;
if (incomingCount === 0) {
canLoadMore.value = false;
}
}
</script>

<template>
<div
v-if="notifications?.length === 0"
class="h-full flex flex-col items-center justify-center gap-3"
>
<div v-if="notifications?.length === 0" class="h-full flex flex-col items-center justify-center gap-3">
<CheckIcon class="h-10 text-green-600" />
{{
`No ${props.importance?.toLowerCase() ?? ""} notifications to see here!`
}}
{{ `No ${props.importance?.toLowerCase() ?? ''} notifications to see here!` }}
</div>
<!-- The horizontal padding here adjusts for the scrollbar offset -->
<div
v-if="notifications?.length > 0"
v-infinite-scroll="onLoadMore"
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
class="divide-y divide-gray-200 overflow-y-auto pl-7 pr-4 h-full"
>
<NotificationsItem
Expand Down
51 changes: 16 additions & 35 deletions web/components/Notifications/Sidebar.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
<script setup lang="ts">
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/shadcn/sheet";

import { archiveAllNotifications } from "./graphql/notification.query";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
import { useMutation } from '@vue/apollo-composable';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
import { Importance, NotificationType } from "~/composables/gql/graphql";
import { useMutation } from "@vue/apollo-composable";
import { Importance, NotificationType } from '~/composables/gql/graphql';
import { archiveAllNotifications } from './graphql/notification.query';

const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(
archiveAllNotifications
);
const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
const { teleportTarget, determineTeleportTarget } = useTeleport();
const importance = ref<Importance | undefined>(undefined);
</script>
Expand All @@ -27,10 +18,7 @@ const importance = ref<Importance | undefined>(undefined);
</SheetTrigger>

<!-- We remove the horizontal padding from the container to keep the NotificationList's scrollbar in the right place -->
<SheetContent
:to="teleportTarget"
class="w-full sm:max-w-[540px] h-screen px-0"
>
<SheetContent :to="teleportTarget" class="w-full sm:max-w-[540px] h-screen px-0">
<div class="flex flex-col h-full gap-3">
<SheetHeader class="ml-1 px-6">
<SheetTitle>Notifications</SheetTitle>
Expand All @@ -39,10 +27,8 @@ const importance = ref<Importance | undefined>(undefined);
<!-- min-h-0 prevents the flex container from expanding beyond its containing bounds. -->
<!-- this is necessary because flex items have a default min-height: auto, -->
<!-- which means they won't shrink below the height of their content, even if you use flex-1 or other flex properties. -->
<Tabs default-value="unread" class="flex-1 flex flex-col min-h-0">
<div
class="flex flex-row justify-between items-center flex-wrap gap-2 px-6"
>
<Tabs default-value="unread" class="flex-1 flex flex-col min-h-0" activation-mode="manual">
<div class="flex flex-row justify-between items-center flex-wrap gap-2 px-6">
<TabsList class="ml-[1px]">
<TabsTrigger value="unread"> Unread </TabsTrigger>
<TabsTrigger value="archived"> Archived </TabsTrigger>
Expand All @@ -59,13 +45,14 @@ const importance = ref<Importance | undefined>(undefined);
</Button>

<Select
@update:model-value="(val) => {importance = val as Importance}"
@update:model-value="
(val) => {
importance = val as Importance;
}
"
>
<SelectTrigger class="bg-secondary border-0 h-auto">
<SelectValue
class="text-muted-foreground"
placeholder="Filter"
/>
<SelectValue class="text-muted-foreground" placeholder="Filter" />
</SelectTrigger>
<SelectContent :to="teleportTarget">
<SelectGroup>
Expand All @@ -79,17 +66,11 @@ const importance = ref<Importance | undefined>(undefined);
</div>

<TabsContent value="unread" class="flex-1 min-h-0 mt-3">
<NotificationsList
:importance="importance"
:type="NotificationType.Unread"
/>
<NotificationsList :importance="importance" :type="NotificationType.Unread" />
</TabsContent>

<TabsContent value="archived" class="flex-1 min-h-0 mt-3">
<NotificationsList
:importance="importance"
:type="NotificationType.Archive"
/>
<NotificationsList :importance="importance" :type="NotificationType.Archive" />
</TabsContent>
</Tabs>
</div>
Expand Down
55 changes: 23 additions & 32 deletions web/helpers/create-apollo-client.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import {
from,
ApolloClient,
ApolloLink,
createHttpLink,
from,
Observable,
split,
ApolloLink, Observable } from "@apollo/client/core/index.js";

import { onError } from "@apollo/client/link/error/index.js";
import { RetryLink } from "@apollo/client/link/retry/index.js";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions/index.js";
import { getMainDefinition } from "@apollo/client/utilities/index.js";
import { provideApolloClient } from "@vue/apollo-composable";
import { createClient } from "graphql-ws";
import { WEBGUI_GRAPHQL } from "./urls";
import { createApolloCache } from "./apollo-cache";
import { useServerStore } from "~/store/server";
} from '@apollo/client/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { provideApolloClient } from '@vue/apollo-composable';
import { useServerStore } from '~/store/server';
import { createClient } from 'graphql-ws';
import { createApolloCache } from './apollo-cache';
import { WEBGUI_GRAPHQL } from './urls';

const httpEndpoint = WEBGUI_GRAPHQL;
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace("http", "ws"));
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws'));

// const headers = { 'x-api-key': serverStore.apiKey };
const headers = {};

const httpLink = createHttpLink({
uri: httpEndpoint.toString(),
headers,
credentials: "include",
credentials: 'include',
});

const wsLink = new GraphQLWsLink(
Expand All @@ -41,12 +42,9 @@ const errorLink = onError(({ graphQLErrors, networkError }: any) => {
if (graphQLErrors) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
graphQLErrors.map((error: any) => {
console.error("[GraphQL error]", error);
const errorMsg =
error.error && error.error.message
? error.error.message
: error.message;
if (errorMsg && errorMsg.includes("offline")) {
console.error('[GraphQL error]', error);
const errorMsg = error.error?.message ?? error.message;
if (errorMsg?.includes('offline')) {
// @todo restart the api
}
return error.message;
Expand All @@ -56,11 +54,8 @@ const errorLink = onError(({ graphQLErrors, networkError }: any) => {
if (networkError) {
console.error(`[Network error]: ${networkError}`);
const msg = networkError.message ? networkError.message : networkError;
if (
typeof msg === "string" &&
msg.includes("Unexpected token < in JSON at position 0")
) {
return "Unraid API • CORS Error";
if (typeof msg === 'string' && msg.includes('Unexpected token < in JSON at position 0')) {
return 'Unraid API • CORS Error';
}
return msg;
}
Expand All @@ -82,11 +77,10 @@ const retryLink = new RetryLink({

const disableClientLink = new ApolloLink((operation, forward) => {
const serverStore = useServerStore();
const { connectPluginInstalled, guid} = toRefs(serverStore);
console.log("serverStore.connectPluginInstalled", connectPluginInstalled?.value, guid?.value);
const { connectPluginInstalled } = toRefs(serverStore);
if (!connectPluginInstalled?.value) {
return new Observable((observer) => {
console.warn("connectPluginInstalled is false, aborting request");
console.warn('connectPluginInstalled is false, aborting request');
observer.complete();
});
}
Expand All @@ -96,10 +90,7 @@ const disableClientLink = new ApolloLink((operation, forward) => {
const splitLinks = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
wsLink,
httpLink
Expand Down