feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type SharedNotificationSummary = {
|
||||
total: number;
|
||||
unread: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
show_unread_badge?: boolean;
|
||||
};
|
||||
|
||||
export type SharedNotificationSummarySnapshot = {
|
||||
summary: SharedNotificationSummary | null;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
|
||||
|
||||
type SummaryStore = {
|
||||
settings: ApiSettings;
|
||||
listeners: Map<Listener, number>;
|
||||
snapshot: SharedNotificationSummarySnapshot;
|
||||
inFlight: Promise<void> | null;
|
||||
lastFetchedAt: number;
|
||||
timerId: number | null;
|
||||
focusListener: () => void;
|
||||
changeListener: () => void;
|
||||
};
|
||||
|
||||
const stores = new Map<string, SummaryStore>();
|
||||
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
|
||||
summary: null,
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
function storeKey(settings: ApiSettings, scopeKey: string): string {
|
||||
return [
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
settings.accessToken,
|
||||
scopeKey
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function notify(store: SummaryStore): void {
|
||||
for (const listener of store.listeners.keys()) {
|
||||
listener(store.snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function minimumInterval(store: SummaryStore): number {
|
||||
return Math.max(
|
||||
5_000,
|
||||
Math.min(...Array.from(store.listeners.values()))
|
||||
);
|
||||
}
|
||||
|
||||
function refresh(store: SummaryStore, force = false): Promise<void> {
|
||||
if (store.inFlight) return store.inFlight;
|
||||
if (
|
||||
!force &&
|
||||
store.lastFetchedAt > 0 &&
|
||||
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (store.snapshot.summary === null) {
|
||||
store.snapshot = { ...store.snapshot, loading: true, error: null };
|
||||
notify(store);
|
||||
}
|
||||
const request = apiFetch<SharedNotificationSummary>(
|
||||
store.settings,
|
||||
"/api/v1/notifications/summary"
|
||||
)
|
||||
.then((summary) => {
|
||||
store.lastFetchedAt = Date.now();
|
||||
store.snapshot = { summary, loading: false, error: null };
|
||||
notify(store);
|
||||
})
|
||||
.catch((error) => {
|
||||
store.snapshot = {
|
||||
...store.snapshot,
|
||||
loading: false,
|
||||
error
|
||||
};
|
||||
notify(store);
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.inFlight === request) store.inFlight = null;
|
||||
});
|
||||
store.inFlight = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
function schedule(store: SummaryStore): void {
|
||||
if (store.timerId !== null) window.clearInterval(store.timerId);
|
||||
if (store.listeners.size === 0) {
|
||||
store.timerId = null;
|
||||
return;
|
||||
}
|
||||
store.timerId = window.setInterval(
|
||||
() => void refresh(store, true),
|
||||
minimumInterval(store)
|
||||
);
|
||||
}
|
||||
|
||||
function createStore(settings: ApiSettings): SummaryStore {
|
||||
const store = {} as SummaryStore;
|
||||
store.settings = settings;
|
||||
store.listeners = new Map();
|
||||
store.snapshot = { ...EMPTY_SNAPSHOT };
|
||||
store.inFlight = null;
|
||||
store.lastFetchedAt = 0;
|
||||
store.timerId = null;
|
||||
store.focusListener = () => void refresh(store);
|
||||
store.changeListener = () => void refresh(store, true);
|
||||
return store;
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
settings: ApiSettings,
|
||||
scopeKey: string,
|
||||
intervalMs: number,
|
||||
listener: Listener
|
||||
): { store: SummaryStore; unsubscribe: () => void } {
|
||||
const key = storeKey(settings, scopeKey);
|
||||
let store = stores.get(key);
|
||||
if (!store) {
|
||||
store = createStore(settings);
|
||||
stores.set(key, store);
|
||||
}
|
||||
const firstListener = store.listeners.size === 0;
|
||||
store.listeners.set(listener, Math.max(5_000, intervalMs));
|
||||
if (firstListener) {
|
||||
window.addEventListener("focus", store.focusListener);
|
||||
window.addEventListener(
|
||||
"govoplan:notifications-changed",
|
||||
store.changeListener
|
||||
);
|
||||
}
|
||||
listener(store.snapshot);
|
||||
schedule(store);
|
||||
void refresh(store);
|
||||
return {
|
||||
store,
|
||||
unsubscribe: () => {
|
||||
store?.listeners.delete(listener);
|
||||
if (store?.listeners.size === 0) {
|
||||
schedule(store);
|
||||
window.removeEventListener("focus", store.focusListener);
|
||||
window.removeEventListener(
|
||||
"govoplan:notifications-changed",
|
||||
store.changeListener
|
||||
);
|
||||
stores.delete(key);
|
||||
} else if (store) {
|
||||
schedule(store);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useSharedNotificationSummary(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
enabled: boolean;
|
||||
scopeKey: string;
|
||||
intervalMs?: number;
|
||||
refreshKey?: string | number;
|
||||
}
|
||||
): SharedNotificationSummarySnapshot {
|
||||
const [snapshot, setSnapshot] =
|
||||
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
|
||||
const activeStore = useRef<SummaryStore | null>(null);
|
||||
const previousRefreshKey = useRef(options.refreshKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
activeStore.current = null;
|
||||
setSnapshot(EMPTY_SNAPSHOT);
|
||||
return;
|
||||
}
|
||||
const subscription = subscribe(
|
||||
settings,
|
||||
options.scopeKey,
|
||||
options.intervalMs ?? 60_000,
|
||||
setSnapshot
|
||||
);
|
||||
activeStore.current = subscription.store;
|
||||
return () => {
|
||||
activeStore.current = null;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [
|
||||
options.enabled,
|
||||
options.intervalMs,
|
||||
options.scopeKey,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousRefreshKey.current === options.refreshKey) return;
|
||||
previousRefreshKey.current = options.refreshKey;
|
||||
if (activeStore.current) void refresh(activeStore.current, true);
|
||||
}, [options.refreshKey]);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
Reference in New Issue
Block a user