@xenide-io/the-old-ui-theme 0.9.13 → 0.9.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from "react";
4
- import { useRouter } from "next/navigation";
3
+ import { useState } from "react";
5
4
  import { Bell } from "iconoir-react";
6
5
 
7
6
  import type { SuiteDropdownMenuComponent } from "../lib/injected";
8
- import {
9
- resolveSuiteNotificationHref,
10
- suiteAppBaseUrl,
11
- suiteAppSlugForNotificationSource,
12
- } from "../lib/apps";
7
+ import { useSuiteNotifications } from "../lib/suite-notifications";
13
8
 
14
- const POLL_MS = 60_000;
15
-
16
- /** Cross-app (suite) notification from `/api/notifications/` — shared across ShellStack apps. */
17
- export interface SuiteNotification {
18
- id: string;
19
- title: string;
20
- body: string;
21
- href: string;
22
- kind: string;
23
- source_app: string;
24
- read_at: string | null;
25
- created_at: string;
26
- workspace: string | null;
27
- organisation: string | null;
28
- }
29
-
30
- export interface SuiteNotificationsResponse {
31
- notifications: SuiteNotification[];
32
- unread_count: number;
33
- }
9
+ export type {
10
+ SuiteNotification,
11
+ SuiteNotificationsResponse,
12
+ } from "../lib/suite-notifications";
34
13
 
35
14
  function timeAgo(iso: string): string {
36
15
  const then = new Date(iso).getTime();
@@ -44,155 +23,35 @@ function timeAgo(iso: string): string {
44
23
  return `${Math.round(hrs / 24)}d ago`;
45
24
  }
46
25
 
26
+ /**
27
+ * Presentational notification bell. State, polling, caching and navigation
28
+ * live in SuiteNotificationsProvider — this just renders the shared context
29
+ * and forwards user intent back to it.
30
+ */
47
31
  export function SuiteNotificationBell({
48
- fetchNotifications,
49
- markRead,
50
- markAllRead,
51
32
  dropdownMenu: DropdownMenu,
52
- cacheKey = "suite-notifications-cache",
53
33
  dataTest = "suite-notifications",
54
34
  triggerId,
55
35
  triggerDataTest,
56
36
  }: {
57
- fetchNotifications: () => Promise<SuiteNotificationsResponse>;
58
- markRead: (id: string) => Promise<unknown>;
59
- markAllRead: () => Promise<unknown>;
60
37
  dropdownMenu: SuiteDropdownMenuComponent;
61
- /** sessionStorage key for stale-while-revalidate; `null` disables caching. */
62
- cacheKey?: string | null;
63
38
  dataTest?: string;
64
39
  triggerId?: string;
65
40
  triggerDataTest?: string;
66
41
  }) {
67
- const router = useRouter();
42
+ const {
43
+ notifications,
44
+ unreadCount: unread,
45
+ refresh,
46
+ select,
47
+ markAllRead,
48
+ } = useSuiteNotifications();
68
49
  const [open, setOpen] = useState(false);
69
- const [items, setItems] = useState<SuiteNotification[]>([]);
70
- const [unread, setUnread] = useState(0);
71
-
72
- const load = useCallback(async () => {
73
- try {
74
- const data = await fetchNotifications();
75
- setItems(data.notifications);
76
- setUnread(data.unread_count);
77
- // Stale-while-revalidate: keep last inbox for instant paint next visit.
78
- if (cacheKey) {
79
- try {
80
- sessionStorage.setItem(
81
- cacheKey,
82
- JSON.stringify({
83
- notifications: data.notifications,
84
- unread_count: data.unread_count,
85
- }),
86
- );
87
- } catch {
88
- // ignore quota / private mode
89
- }
90
- }
91
- } catch {
92
- // Non-critical chrome — never surface bell errors to the user.
93
- }
94
- }, [fetchNotifications, cacheKey]);
95
-
96
- useEffect(() => {
97
- if (cacheKey) {
98
- queueMicrotask(() => {
99
- try {
100
- const raw = sessionStorage.getItem(cacheKey);
101
- if (raw) {
102
- const cached = JSON.parse(raw) as {
103
- notifications?: SuiteNotification[];
104
- unread_count?: number;
105
- };
106
- if (Array.isArray(cached.notifications)) {
107
- setItems(cached.notifications);
108
- setUnread(cached.unread_count ?? 0);
109
- }
110
- }
111
- } catch {
112
- // ignore
113
- }
114
- });
115
- }
116
- const kick = window.setTimeout(() => void load(), 0);
117
- const id = window.setInterval(() => void load(), POLL_MS);
118
- return () => {
119
- window.clearTimeout(kick);
120
- window.clearInterval(id);
121
- };
122
- }, [load, cacheKey]);
123
-
124
- const onOpenChange = useCallback(
125
- (next: boolean) => {
126
- setOpen(next);
127
- if (next) void load();
128
- },
129
- [load],
130
- );
131
-
132
- const openItem = useCallback(
133
- async (n: SuiteNotification) => {
134
- setOpen(false);
135
- if (!n.read_at) {
136
- setItems((prev) =>
137
- prev.map((i) =>
138
- i.id === n.id ? { ...i, read_at: new Date().toISOString() } : i,
139
- ),
140
- );
141
- setUnread((u) => Math.max(0, u - 1));
142
- try {
143
- await markRead(n.id);
144
- } catch {
145
- // ignore
146
- }
147
- }
148
- const target = resolveSuiteNotificationHref(n.href, n.source_app);
149
- if (!target) return;
150
- if (/^https?:\/\//i.test(target)) {
151
- try {
152
- const url = new URL(target);
153
- const sourceApp = suiteAppSlugForNotificationSource(n.source_app);
154
- const path = `${url.pathname}${url.search}${url.hash}`;
155
- if (url.origin === window.location.origin) {
156
- router.push(path);
157
- return;
158
- }
159
- if (sourceApp && sourceApp !== "shellstack") {
160
- const sourceOrigin = new URL(suiteAppBaseUrl(sourceApp)).origin;
161
- if (url.origin === sourceOrigin) {
162
- const launchUrl = new URL(
163
- `/launch/${sourceApp}`,
164
- suiteAppBaseUrl("shellstack"),
165
- );
166
- launchUrl.searchParams.set("next", path);
167
- window.location.assign(launchUrl.toString());
168
- return;
169
- }
170
- }
171
- } catch {
172
- // Fall through to the original external link.
173
- }
174
- window.location.assign(target);
175
- return;
176
- }
177
- router.push(target);
178
- },
179
- [router, markRead],
180
- );
181
50
 
182
- const markAll = useCallback(async () => {
183
- setItems((prev) =>
184
- prev.map((i) => ({
185
- ...i,
186
- read_at: i.read_at ?? new Date().toISOString(),
187
- })),
188
- );
189
- setUnread(0);
190
- try {
191
- await markAllRead();
192
- } catch {
193
- // ignore
194
- }
195
- }, [markAllRead]);
51
+ const onOpenChange = (next: boolean) => {
52
+ setOpen(next);
53
+ if (next) refresh();
54
+ };
196
55
 
197
56
  return (
198
57
  <DropdownMenu
@@ -225,7 +84,7 @@ export function SuiteNotificationBell({
225
84
  type="button"
226
85
  id={`${dataTest}-mark-all`}
227
86
  data-test={`${dataTest}-mark-all`}
228
- onClick={markAll}
87
+ onClick={markAllRead}
229
88
  className="text-xs font-medium text-ph-brand hover:underline"
230
89
  >
231
90
  Mark all read
@@ -233,17 +92,20 @@ export function SuiteNotificationBell({
233
92
  ) : null}
234
93
  </div>
235
94
  <div className="max-h-80 overflow-y-auto py-1">
236
- {items.length === 0 ? (
95
+ {notifications.length === 0 ? (
237
96
  <p className="px-3 py-6 text-center text-sm text-ph-mutedtext">
238
97
  You&apos;re all caught up.
239
98
  </p>
240
99
  ) : (
241
- items.map((n) => (
100
+ notifications.map((n) => (
242
101
  <button
243
102
  key={n.id}
244
103
  id={`${dataTest}-item-${n.id}`}
245
104
  data-test={`${dataTest}-item-${n.id}`}
246
- onClick={() => void openItem(n)}
105
+ onClick={() => {
106
+ setOpen(false);
107
+ select(n);
108
+ }}
247
109
  className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
248
110
  >
249
111
  <span className="flex items-center gap-2">
@@ -10,11 +10,15 @@ export {
10
10
  type SuiteAppEntry,
11
11
  type SuiteAppSelectOptions,
12
12
  } from "./components/app-switcher";
13
+ export { SuiteNotificationBell } from "./components/suite-notification-bell";
13
14
  export {
14
- SuiteNotificationBell,
15
+ SuiteNotificationsProvider,
16
+ useSuiteNotifications,
17
+ SUITE_NOTIFICATIONS_POLL_MS,
15
18
  type SuiteNotification,
16
19
  type SuiteNotificationsResponse,
17
- } from "./components/suite-notification-bell";
20
+ type SuiteNotificationsContextValue,
21
+ } from "./lib/suite-notifications";
18
22
  export { CommandPaletteHost } from "./components/command-palette-host";
19
23
  export { DeferredChrome } from "./components/deferred-chrome";
20
24
  export { SuiteMobileDrawer } from "./components/suite-mobile-drawer";
@@ -174,9 +178,7 @@ export {
174
178
  type SuiteDirectoryProject,
175
179
  type SuiteDirectoryProjectFields,
176
180
  } from "./components/suite-clients-projects-directory";
177
- export {
178
- SuiteEntityIcon,
179
- } from "./components/suite-entity-icon";
181
+ export { SuiteEntityIcon } from "./components/suite-entity-icon";
180
182
  export {
181
183
  SuitePaletteLibrary,
182
184
  type SuitePalettePick,
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { useRouter } from "next/navigation";
13
+
14
+ import {
15
+ resolveSuiteNotificationHref,
16
+ suiteAppBaseUrl,
17
+ suiteAppSlugForNotificationSource,
18
+ } from "./apps";
19
+
20
+ /** Cross-app (suite) notification from `/api/notifications/` — shared across ShellStack apps. */
21
+ export interface SuiteNotification {
22
+ id: string;
23
+ title: string;
24
+ body: string;
25
+ href: string;
26
+ kind: string;
27
+ source_app: string;
28
+ read_at: string | null;
29
+ created_at: string;
30
+ workspace: string | null;
31
+ organisation: string | null;
32
+ }
33
+
34
+ export interface SuiteNotificationsResponse {
35
+ notifications: SuiteNotification[];
36
+ unread_count: number;
37
+ }
38
+
39
+ export interface SuiteNotificationsContextValue {
40
+ notifications: SuiteNotification[];
41
+ unreadCount: number;
42
+ refresh: () => void;
43
+ select: (notification: SuiteNotification) => void;
44
+ markAllRead: () => void;
45
+ }
46
+
47
+ export const SUITE_NOTIFICATIONS_POLL_MS = 60_000;
48
+
49
+ const SuiteNotificationsContext =
50
+ createContext<SuiteNotificationsContextValue | null>(null);
51
+
52
+ /**
53
+ * Read the shared notification state. Must be rendered inside a
54
+ * `SuiteNotificationsProvider`: one provider per app owns the single poller,
55
+ * however many bells consume it.
56
+ */
57
+ export function useSuiteNotifications(): SuiteNotificationsContextValue {
58
+ const value = useContext(SuiteNotificationsContext);
59
+ if (!value) {
60
+ throw new Error(
61
+ "useSuiteNotifications must be used within a SuiteNotificationsProvider",
62
+ );
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Application-layer notification runtime. Owns fetching, polling cadence,
69
+ * tab-visibility gating, stale-while-revalidate caching, optimistic read
70
+ * state, and cross-app navigation. The UI (SuiteNotificationBell) stays
71
+ * presentational and consumes this context, so multiple bells share one
72
+ * request per interval instead of polling independently.
73
+ */
74
+ export function SuiteNotificationsProvider({
75
+ fetchNotifications,
76
+ markRead,
77
+ markAllRead: markAllReadRequest,
78
+ pollMs = SUITE_NOTIFICATIONS_POLL_MS,
79
+ cacheKey = "suite-notifications-cache",
80
+ enabled = true,
81
+ children,
82
+ }: {
83
+ fetchNotifications: () => Promise<SuiteNotificationsResponse>;
84
+ markRead: (id: string) => Promise<unknown>;
85
+ markAllRead: () => Promise<unknown>;
86
+ /** Poll cadence while the tab is visible. Defaults to 60s. */
87
+ pollMs?: number;
88
+ /** sessionStorage key for stale-while-revalidate; `null` disables caching. */
89
+ cacheKey?: string | null;
90
+ /** Set false to suspend fetching entirely (e.g. before auth resolves). */
91
+ enabled?: boolean;
92
+ children: ReactNode;
93
+ }) {
94
+ const router = useRouter();
95
+ const [notifications, setNotifications] = useState<SuiteNotification[]>([]);
96
+ const [unreadCount, setUnreadCount] = useState(0);
97
+
98
+ const load = useCallback(async () => {
99
+ try {
100
+ const data = await fetchNotifications();
101
+ setNotifications(data.notifications);
102
+ setUnreadCount(data.unread_count);
103
+ if (cacheKey) {
104
+ try {
105
+ sessionStorage.setItem(
106
+ cacheKey,
107
+ JSON.stringify({
108
+ notifications: data.notifications,
109
+ unread_count: data.unread_count,
110
+ }),
111
+ );
112
+ } catch {
113
+ // ignore quota / private mode
114
+ }
115
+ }
116
+ } catch {
117
+ // Non-critical chrome — never surface bell errors to the user.
118
+ }
119
+ }, [fetchNotifications, cacheKey]);
120
+
121
+ useEffect(() => {
122
+ if (!enabled) return;
123
+
124
+ if (cacheKey) {
125
+ queueMicrotask(() => {
126
+ try {
127
+ const raw = sessionStorage.getItem(cacheKey);
128
+ if (!raw) return;
129
+ const cached = JSON.parse(raw) as {
130
+ notifications?: SuiteNotification[];
131
+ unread_count?: number;
132
+ };
133
+ if (Array.isArray(cached.notifications)) {
134
+ setNotifications(cached.notifications);
135
+ setUnreadCount(cached.unread_count ?? 0);
136
+ }
137
+ } catch {
138
+ // ignore
139
+ }
140
+ });
141
+ }
142
+
143
+ let timer: number | undefined;
144
+ const stop = () => {
145
+ if (timer !== undefined) {
146
+ window.clearInterval(timer);
147
+ timer = undefined;
148
+ }
149
+ };
150
+ const start = () => {
151
+ if (timer !== undefined) return;
152
+ void load();
153
+ timer = window.setInterval(() => void load(), pollMs);
154
+ };
155
+ const onVisibilityChange = () => {
156
+ if (document.visibilityState === "visible") start();
157
+ else stop();
158
+ };
159
+
160
+ if (document.visibilityState === "visible") start();
161
+ document.addEventListener("visibilitychange", onVisibilityChange);
162
+ return () => {
163
+ stop();
164
+ document.removeEventListener("visibilitychange", onVisibilityChange);
165
+ };
166
+ }, [enabled, load, pollMs, cacheKey]);
167
+
168
+ const select = useCallback(
169
+ (notification: SuiteNotification) => {
170
+ if (!notification.read_at) {
171
+ setNotifications((prev) =>
172
+ prev.map((item) =>
173
+ item.id === notification.id
174
+ ? { ...item, read_at: new Date().toISOString() }
175
+ : item,
176
+ ),
177
+ );
178
+ setUnreadCount((count) => Math.max(0, count - 1));
179
+ void (async () => {
180
+ try {
181
+ await markRead(notification.id);
182
+ } catch {
183
+ // ignore
184
+ }
185
+ })();
186
+ }
187
+
188
+ const target = resolveSuiteNotificationHref(
189
+ notification.href,
190
+ notification.source_app,
191
+ );
192
+ if (!target) return;
193
+ if (/^https?:\/\//i.test(target)) {
194
+ try {
195
+ const url = new URL(target);
196
+ const sourceApp = suiteAppSlugForNotificationSource(
197
+ notification.source_app,
198
+ );
199
+ const path = `${url.pathname}${url.search}${url.hash}`;
200
+ if (url.origin === window.location.origin) {
201
+ router.push(path);
202
+ return;
203
+ }
204
+ if (sourceApp && sourceApp !== "shellstack") {
205
+ const sourceOrigin = new URL(suiteAppBaseUrl(sourceApp)).origin;
206
+ if (url.origin === sourceOrigin) {
207
+ const launchUrl = new URL(
208
+ `/launch/${sourceApp}`,
209
+ suiteAppBaseUrl("shellstack"),
210
+ );
211
+ launchUrl.searchParams.set("next", path);
212
+ window.location.assign(launchUrl.toString());
213
+ return;
214
+ }
215
+ }
216
+ } catch {
217
+ // Fall through to the original external link.
218
+ }
219
+ window.location.assign(target);
220
+ return;
221
+ }
222
+ router.push(target);
223
+ },
224
+ [router, markRead],
225
+ );
226
+
227
+ const markAllRead = useCallback(() => {
228
+ setNotifications((prev) =>
229
+ prev.map((item) => ({
230
+ ...item,
231
+ read_at: item.read_at ?? new Date().toISOString(),
232
+ })),
233
+ );
234
+ setUnreadCount(0);
235
+ void (async () => {
236
+ try {
237
+ await markAllReadRequest();
238
+ } catch {
239
+ // ignore
240
+ }
241
+ })();
242
+ }, [markAllReadRequest]);
243
+
244
+ const value = useMemo<SuiteNotificationsContextValue>(
245
+ () => ({
246
+ notifications,
247
+ unreadCount,
248
+ refresh: () => void load(),
249
+ select,
250
+ markAllRead,
251
+ }),
252
+ [notifications, unreadCount, load, select, markAllRead],
253
+ );
254
+
255
+ return (
256
+ <SuiteNotificationsContext.Provider value={value}>
257
+ {children}
258
+ </SuiteNotificationsContext.Provider>
259
+ );
260
+ }