feat: frontend notification manager
This commit is contained in:
@ -1,8 +1,15 @@
|
|||||||
import { type Component, type JSX, Show } from "solid-js";
|
import {
|
||||||
|
type Component,
|
||||||
|
type JSX,
|
||||||
|
ParentProps,
|
||||||
|
Show,
|
||||||
|
useContext,
|
||||||
|
} from "solid-js";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { Navigate } from "@solidjs/router";
|
import { Navigate } from "@solidjs/router";
|
||||||
import { save_token } from "tauri-plugin-ios-shared-token-api";
|
import { save_token } from "tauri-plugin-ios-shared-token-api";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { Notifications, NotificationsContext } from "./notifications";
|
||||||
|
|
||||||
export const isTokenValid = (): boolean => {
|
export const isTokenValid = (): boolean => {
|
||||||
const token = localStorage.getItem("access");
|
const token = localStorage.getItem("access");
|
||||||
@ -19,9 +26,26 @@ export const isTokenValid = (): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProtectedRoute: Component<{ children?: JSX.Element }> = (
|
const WithNotifications: Component<ParentProps> = (props) => {
|
||||||
props,
|
const notifications = Notifications();
|
||||||
) => {
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={notifications}>
|
||||||
|
{props.children}
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const notifications = useContext(NotificationsContext);
|
||||||
|
if (notifications == null) {
|
||||||
|
throw new Error("Notifications must be defined when using this hook");
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProtectedRoute: Component<ParentProps> = (props) => {
|
||||||
const isValid = isTokenValid();
|
const isValid = isTokenValid();
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
@ -42,7 +66,7 @@ export const ProtectedRoute: Component<{ children?: JSX.Element }> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={isValid} fallback={<Navigate href="/login" />}>
|
<Show when={isValid} fallback={<Navigate href="/login" />}>
|
||||||
{props.children}
|
<WithNotifications>{props.children}</WithNotifications>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -17,12 +17,16 @@ import { base, type UserImage } from "./network";
|
|||||||
import { useSearchImageContext } from "./contexts/SearchImageContext";
|
import { useSearchImageContext } from "./contexts/SearchImageContext";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { useSetEntity } from "./WithEntityDialog";
|
import { useSetEntity } from "./WithEntityDialog";
|
||||||
|
import { useNotifications } from "./ProtectedRoute";
|
||||||
|
|
||||||
export const Search = () => {
|
export const Search = () => {
|
||||||
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
|
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = createSignal("");
|
const [searchQuery, setSearchQuery] = createSignal("");
|
||||||
const setEntity = useSetEntity();
|
const setEntity = useSetEntity();
|
||||||
|
|
||||||
|
const notifications = useNotifications();
|
||||||
|
console.log(notifications);
|
||||||
|
|
||||||
const { images, imagesWithProperties, onRefetchImages } =
|
const { images, imagesWithProperties, onRefetchImages } =
|
||||||
useSearchImageContext();
|
useSearchImageContext();
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ type BaseRequestParams = Partial<{
|
|||||||
method: "GET" | "POST";
|
method: "GET" | "POST";
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const base = "https://haystack.johncosta.tech";
|
export const base = "http://localhost:3040";
|
||||||
|
|
||||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||||
return new Request(`${base}/${path}`, {
|
return new Request(`${base}/${path}`, {
|
||||||
|
85
frontend/src/notifications/index.ts
Normal file
85
frontend/src/notifications/index.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { createStore } from "solid-js/store";
|
||||||
|
import {
|
||||||
|
type InferOutput,
|
||||||
|
literal,
|
||||||
|
pipe,
|
||||||
|
safeParse,
|
||||||
|
strictObject,
|
||||||
|
string,
|
||||||
|
union,
|
||||||
|
uuid,
|
||||||
|
} from "valibot";
|
||||||
|
import { base } from "../network";
|
||||||
|
import { createContext } from "solid-js";
|
||||||
|
|
||||||
|
const processingImagesValidator = strictObject({
|
||||||
|
ImageID: pipe(string(), uuid()),
|
||||||
|
Status: union([literal("in-process"), literal("complete")]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type NotificationState = {
|
||||||
|
ProcessingImages: Record<
|
||||||
|
string,
|
||||||
|
InferOutput<typeof processingImagesValidator>["Status"]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Notifications = () => {
|
||||||
|
const [state, setState] = createStore<NotificationState>({
|
||||||
|
ProcessingImages: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const access = localStorage.getItem("access");
|
||||||
|
if (access == null) {
|
||||||
|
throw new Error("Access token not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataEventListener = (e: MessageEvent<unknown>) => {
|
||||||
|
const processingImage = safeParse(processingImagesValidator, e.data);
|
||||||
|
if (!processingImage.success) {
|
||||||
|
console.error("Processing image could not be parsed.", e.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ImageID, Status } = processingImage.output;
|
||||||
|
|
||||||
|
if (ImageID in state.ProcessingImages) {
|
||||||
|
const localImageStatus = state.ProcessingImages[ImageID];
|
||||||
|
if (localImageStatus !== "in-process" || Status !== "complete") {
|
||||||
|
console.error(
|
||||||
|
"Invalid state, an image present must always be in process",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("ProcessingImages", (images) => {
|
||||||
|
delete images[ImageID];
|
||||||
|
|
||||||
|
// TODO: this might not work because it's not a new object.
|
||||||
|
return images;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (Status !== "in-process") {
|
||||||
|
console.error(
|
||||||
|
"Invalid state, at this point a new image that isn't present should be in-progress",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("ProcessingImages", {
|
||||||
|
[ImageID]: Status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = new EventSource(`${base}/notifications?token=${access}`);
|
||||||
|
|
||||||
|
events.addEventListener("data", dataEventListener);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationsContext =
|
||||||
|
createContext<ReturnType<typeof Notifications>>();
|
Reference in New Issue
Block a user