@xenide-io/the-old-ui-theme 0.9.13 → 0.9.14
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.
- package/dist/suite.d.mts +49 -4
- package/dist/suite.d.ts +49 -4
- package/dist/suite.js +892 -778
- package/dist/suite.mjs +617 -498
- package/package.json +1 -1
- package/src/suite/components/suite-integration-picker.test.tsx +36 -2
- package/src/suite/components/suite-integration-picker.tsx +104 -72
- package/src/suite/components/suite-notification-bell.test.tsx +12 -8
- package/src/suite/components/suite-notification-bell.tsx +23 -170
- package/src/suite/index.ts +7 -5
- package/src/suite/lib/suite-notifications.tsx +282 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenide-io/the-old-ui-theme",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14",
|
|
4
4
|
"description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
|
|
5
5
|
"author": "Xenide",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,39 @@ describe("SuiteIntegrationPicker", () => {
|
|
|
46
46
|
expect(screen.queryByText("Linear")).not.toBeInTheDocument();
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it("filters to bridge-capable apps when Shelly Bridge only is on", async () => {
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
|
|
52
|
+
render(
|
|
53
|
+
<SuiteIntegrationPicker
|
|
54
|
+
open
|
|
55
|
+
items={[
|
|
56
|
+
{
|
|
57
|
+
id: "bridged",
|
|
58
|
+
label: "Bridged app",
|
|
59
|
+
description: "Can bridge into Tides",
|
|
60
|
+
bridgeTargets: ["tides"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "shelly-only",
|
|
64
|
+
label: "Shelly only app",
|
|
65
|
+
description: "AI only",
|
|
66
|
+
},
|
|
67
|
+
]}
|
|
68
|
+
onClose={vi.fn()}
|
|
69
|
+
onConnect={vi.fn()}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(screen.getByText("Bridged app")).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText("Shelly only app")).toBeInTheDocument();
|
|
75
|
+
|
|
76
|
+
await user.click(screen.getByLabelText("Shelly Bridge only"));
|
|
77
|
+
|
|
78
|
+
expect(screen.getByText("Bridged app")).toBeInTheDocument();
|
|
79
|
+
expect(screen.queryByText("Shelly only app")).not.toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
49
82
|
it("shows auth tags and passes the selected method to the connector", async () => {
|
|
50
83
|
const user = userEvent.setup();
|
|
51
84
|
const onConnect = vi.fn();
|
|
@@ -59,6 +92,7 @@ describe("SuiteIntegrationPicker", () => {
|
|
|
59
92
|
label: "Mixed provider",
|
|
60
93
|
description: "Supports multiple connection methods",
|
|
61
94
|
categories: ["calendar", "email"],
|
|
95
|
+
bridgeTargets: ["turtletime", "tides"],
|
|
62
96
|
authSchemes: ["OAUTH2", "API_KEY"],
|
|
63
97
|
},
|
|
64
98
|
]}
|
|
@@ -69,8 +103,8 @@ describe("SuiteIntegrationPicker", () => {
|
|
|
69
103
|
|
|
70
104
|
expect(screen.getByText("OAuth 2.0")).toBeInTheDocument();
|
|
71
105
|
expect(screen.getByText("API key")).toBeInTheDocument();
|
|
72
|
-
expect(screen.
|
|
73
|
-
expect(screen.
|
|
106
|
+
expect(screen.getByTitle("Bridge in TurtleTime")).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByTitle("Bridge in Tides")).toBeInTheDocument();
|
|
74
108
|
|
|
75
109
|
await user.click(screen.getByRole("button", { name: "Connect" }));
|
|
76
110
|
await user.click(screen.getByRole("button", { name: /API key Select/ }));
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ReactNode,
|
|
11
11
|
} from "react";
|
|
12
12
|
import {
|
|
13
|
+
Bridge3d,
|
|
13
14
|
EvPlug as Plug,
|
|
14
15
|
Filter,
|
|
15
16
|
NavArrowDown,
|
|
@@ -18,8 +19,6 @@ import {
|
|
|
18
19
|
Xmark,
|
|
19
20
|
} from "iconoir-react";
|
|
20
21
|
import { Button, Input, Modal } from "@/components/ui";
|
|
21
|
-
import { SUITE_APPS, SUITE_APP_MAP, type SuiteAppSlug } from "@/suite/lib/apps";
|
|
22
|
-
import { SuiteAppIcon } from "@/suite/icons";
|
|
23
22
|
|
|
24
23
|
export interface SuiteIntegrationPickerItem {
|
|
25
24
|
id: string;
|
|
@@ -27,6 +26,8 @@ export interface SuiteIntegrationPickerItem {
|
|
|
27
26
|
description: string;
|
|
28
27
|
icon?: ReactNode;
|
|
29
28
|
categories?: string[];
|
|
29
|
+
/** Product apps this toolkit can actually bridge. Empty means Shelly AI only. */
|
|
30
|
+
bridgeTargets?: string[];
|
|
30
31
|
authSchemes?: string[];
|
|
31
32
|
connecting?: boolean;
|
|
32
33
|
unavailableReason?: string;
|
|
@@ -63,6 +64,13 @@ export interface SuiteIntegrationPickerProps {
|
|
|
63
64
|
category?: string;
|
|
64
65
|
categories?: string[];
|
|
65
66
|
onCategoryChange?: (value: string) => void;
|
|
67
|
+
/**
|
|
68
|
+
* When provided, the "Shelly Bridge only" filter is controlled by the caller
|
|
69
|
+
* (so it can refetch a bridge-filtered catalogue server-side). When omitted,
|
|
70
|
+
* the picker filters the loaded items locally.
|
|
71
|
+
*/
|
|
72
|
+
bridgeOnly?: boolean;
|
|
73
|
+
onBridgeOnlyChange?: (value: boolean) => void;
|
|
66
74
|
onClose: () => void;
|
|
67
75
|
onConnect: (
|
|
68
76
|
item: SuiteIntegrationPickerItem,
|
|
@@ -87,7 +95,7 @@ function IntegrationSkeletonCard() {
|
|
|
87
95
|
aria-hidden="true"
|
|
88
96
|
>
|
|
89
97
|
<div className="flex items-start gap-3">
|
|
90
|
-
<div className="h-
|
|
98
|
+
<div className="h-8 w-8 shrink-0 rounded-md bg-ph-muted" />
|
|
91
99
|
<div className="min-w-0 flex-1 space-y-2 pt-1">
|
|
92
100
|
<div className="h-4 w-3/5 rounded bg-ph-muted" />
|
|
93
101
|
<div className="h-3 w-full rounded bg-ph-muted" />
|
|
@@ -128,46 +136,28 @@ function authSchemesFor(item: SuiteIntegrationPickerItem) {
|
|
|
128
136
|
);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
* knowledge sources feed Kraken, CRM/finance feed the Portal. Unmapped
|
|
136
|
-
* categories default to Kraken's authorised-source search.
|
|
137
|
-
*/
|
|
138
|
-
const CATEGORY_APP_AFFINITY: Record<string, readonly SuiteAppSlug[]> = {
|
|
139
|
-
calendar: ["turtletime", "tides"],
|
|
140
|
-
email: ["turtletime", "tides"],
|
|
141
|
-
code: ["tides", "kraken"],
|
|
142
|
-
chat: ["tides", "kraken"],
|
|
143
|
-
documents: ["kraken"],
|
|
144
|
-
notes: ["kraken"],
|
|
145
|
-
knowledge: ["kraken"],
|
|
146
|
-
file_storage: ["kraken"],
|
|
147
|
-
crm: ["shellstack", "tides"],
|
|
148
|
-
finance: ["shellstack", "tides"],
|
|
149
|
-
payment: ["shellstack", "tides"],
|
|
150
|
-
monitoring: ["tides", "kraken"],
|
|
151
|
-
design: ["tides", "kraken"],
|
|
152
|
-
support: ["tides", "shellstack"],
|
|
153
|
-
ticketing: ["tides", "kraken"],
|
|
139
|
+
const BRIDGE_MARK: Record<string, { src: string; name: string }> = {
|
|
140
|
+
turtletime: { src: "/turtletime-icon.svg", name: "TurtleTime" },
|
|
141
|
+
tides: { src: "/tides-icon.svg", name: "Tides" },
|
|
142
|
+
kraken: { src: "/kraken-icon.svg", name: "Kraken" },
|
|
154
143
|
};
|
|
155
144
|
|
|
156
|
-
const DEFAULT_APP_AFFINITY: readonly SuiteAppSlug[] = ["tides", "kraken"];
|
|
157
|
-
|
|
158
145
|
export function suiteAppsForIntegration(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
146
|
+
itemOrCategories:
|
|
147
|
+
| { bridgeTargets?: readonly string[] }
|
|
148
|
+
| readonly string[]
|
|
149
|
+
| undefined,
|
|
150
|
+
): string[] {
|
|
151
|
+
if (
|
|
152
|
+
!itemOrCategories ||
|
|
153
|
+
Array.isArray(itemOrCategories) ||
|
|
154
|
+
!("bridgeTargets" in itemOrCategories)
|
|
155
|
+
) {
|
|
156
|
+
return [];
|
|
167
157
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
158
|
+
const explicit = itemOrCategories.bridgeTargets;
|
|
159
|
+
if (!Array.isArray(explicit)) return [];
|
|
160
|
+
return explicit.filter((app): app is string => app in BRIDGE_MARK);
|
|
171
161
|
}
|
|
172
162
|
|
|
173
163
|
export function SuiteIntegrationPicker({
|
|
@@ -184,6 +174,8 @@ export function SuiteIntegrationPicker({
|
|
|
184
174
|
category: controlledCategory,
|
|
185
175
|
categories: controlledCategories,
|
|
186
176
|
onCategoryChange,
|
|
177
|
+
bridgeOnly: controlledBridgeOnly,
|
|
178
|
+
onBridgeOnlyChange,
|
|
187
179
|
onClose,
|
|
188
180
|
onConnect,
|
|
189
181
|
onFetchAuthFields,
|
|
@@ -197,6 +189,7 @@ export function SuiteIntegrationPicker({
|
|
|
197
189
|
const [categoryQuery, setCategoryQuery] = useState("");
|
|
198
190
|
const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
|
|
199
191
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
192
|
+
const [bridgeOnlyInternal, setBridgeOnlyInternal] = useState(false);
|
|
200
193
|
const categoryMenuRef = useRef<HTMLDivElement>(null);
|
|
201
194
|
const categoryTriggerRef = useRef<HTMLButtonElement>(null);
|
|
202
195
|
const categorySearchRef = useRef<HTMLInputElement>(null);
|
|
@@ -207,6 +200,7 @@ export function SuiteIntegrationPicker({
|
|
|
207
200
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
|
208
201
|
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
|
209
202
|
const loadMoreInFlightRef = useRef(false);
|
|
203
|
+
const sentinelVisibleRef = useRef(false);
|
|
210
204
|
const authRequestRef = useRef(0);
|
|
211
205
|
const [methodItem, setMethodItem] =
|
|
212
206
|
useState<SuiteIntegrationPickerItem | null>(null);
|
|
@@ -234,11 +228,16 @@ export function SuiteIntegrationPicker({
|
|
|
234
228
|
const categories = controlledCategories ?? itemCategories;
|
|
235
229
|
const activeSearch = controlledSearch ?? query;
|
|
236
230
|
const activeCategory = controlledCategory ?? category;
|
|
231
|
+
const bridgeOnly = controlledBridgeOnly ?? bridgeOnlyInternal;
|
|
232
|
+
const setBridgeOnly = (value: boolean) => {
|
|
233
|
+
setBridgeOnlyInternal(value);
|
|
234
|
+
onBridgeOnlyChange?.(value);
|
|
235
|
+
};
|
|
237
236
|
const categoryLabel = (value: string) =>
|
|
238
237
|
value
|
|
239
238
|
.replace(/[-_]/g, " ")
|
|
240
239
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
241
|
-
const
|
|
240
|
+
const catalogItems =
|
|
242
241
|
onSearchChange || onCategoryChange
|
|
243
242
|
? items
|
|
244
243
|
: items.filter((item) => {
|
|
@@ -250,6 +249,10 @@ export function SuiteIntegrationPicker({
|
|
|
250
249
|
(item.categories ?? []).includes(activeCategory))
|
|
251
250
|
);
|
|
252
251
|
});
|
|
252
|
+
const filtered =
|
|
253
|
+
bridgeOnly && controlledBridgeOnly === undefined
|
|
254
|
+
? catalogItems.filter((item) => (item.bridgeTargets ?? []).length > 0)
|
|
255
|
+
: catalogItems;
|
|
253
256
|
const visibleCategories = categories.filter((value) =>
|
|
254
257
|
categoryLabel(value)
|
|
255
258
|
.toLowerCase()
|
|
@@ -480,27 +483,37 @@ export function SuiteIntegrationPicker({
|
|
|
480
483
|
useEffect(() => {
|
|
481
484
|
const root = scrollViewportRef.current;
|
|
482
485
|
const sentinel = loadMoreSentinelRef.current;
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
!root ||
|
|
486
|
-
!sentinel ||
|
|
487
|
-
!hasMore ||
|
|
488
|
-
!onEndReached ||
|
|
489
|
-
loading ||
|
|
490
|
-
catalogLoading ||
|
|
491
|
-
loadingMore
|
|
492
|
-
) {
|
|
486
|
+
if (!open || !root || !sentinel) {
|
|
487
|
+
sentinelVisibleRef.current = false;
|
|
493
488
|
return;
|
|
494
489
|
}
|
|
495
490
|
|
|
496
491
|
const observer = new IntersectionObserver(
|
|
497
492
|
([entry]) => {
|
|
498
|
-
|
|
493
|
+
// Only auto-load on the rising edge of the sentinel becoming visible.
|
|
494
|
+
// Without this, shrinking the list (search / category / Shelly Bridge
|
|
495
|
+
// only) leaves the sentinel pinned in view and each re-armed observer
|
|
496
|
+
// fires again, chain-loading the whole catalog page after page.
|
|
497
|
+
if (!entry.isIntersecting) {
|
|
498
|
+
sentinelVisibleRef.current = false;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (sentinelVisibleRef.current) return;
|
|
502
|
+
sentinelVisibleRef.current = true;
|
|
503
|
+
if (
|
|
504
|
+
loadMoreInFlightRef.current ||
|
|
505
|
+
!hasMore ||
|
|
506
|
+
!onEndReached ||
|
|
507
|
+
loading ||
|
|
508
|
+
catalogLoading ||
|
|
509
|
+
loadingMore
|
|
510
|
+
) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
499
513
|
loadMoreInFlightRef.current = true;
|
|
500
|
-
observer.unobserve(entry.target);
|
|
501
514
|
onEndReached();
|
|
502
515
|
},
|
|
503
|
-
{ root, rootMargin: "0px 0px
|
|
516
|
+
{ root, rootMargin: "0px 0px 120px", threshold: 0 },
|
|
504
517
|
);
|
|
505
518
|
|
|
506
519
|
observer.observe(sentinel);
|
|
@@ -524,6 +537,24 @@ export function SuiteIntegrationPicker({
|
|
|
524
537
|
{connectionError}
|
|
525
538
|
</p>
|
|
526
539
|
) : null}
|
|
540
|
+
<label
|
|
541
|
+
className="inline-flex w-fit cursor-pointer items-center gap-2 text-sm text-ph-ink"
|
|
542
|
+
data-test={`${dataTest}-bridge-only`}
|
|
543
|
+
>
|
|
544
|
+
<input
|
|
545
|
+
type="checkbox"
|
|
546
|
+
role="switch"
|
|
547
|
+
className="ph-toggle"
|
|
548
|
+
checked={bridgeOnly}
|
|
549
|
+
onChange={(event) => setBridgeOnly(event.target.checked)}
|
|
550
|
+
aria-label="Shelly Bridge only"
|
|
551
|
+
/>
|
|
552
|
+
<Bridge3d
|
|
553
|
+
className="h-4 w-4 shrink-0 text-ph-subtle"
|
|
554
|
+
aria-hidden
|
|
555
|
+
/>
|
|
556
|
+
<span>Shelly Bridge only</span>
|
|
557
|
+
</label>
|
|
527
558
|
<div className="grid gap-4 md:grid-cols-2">
|
|
528
559
|
<div className="relative">
|
|
529
560
|
<Search
|
|
@@ -652,6 +683,8 @@ export function SuiteIntegrationPicker({
|
|
|
652
683
|
const isExpanded = expanded === item.id;
|
|
653
684
|
const longDescription =
|
|
654
685
|
item.description.length > DESCRIPTION_LIMIT;
|
|
686
|
+
const bridgeApps = suiteAppsForIntegration(item);
|
|
687
|
+
const schemes = authSchemesFor(item);
|
|
655
688
|
const text =
|
|
656
689
|
isExpanded || !longDescription
|
|
657
690
|
? item.description
|
|
@@ -671,9 +704,9 @@ export function SuiteIntegrationPicker({
|
|
|
671
704
|
aria-expanded={isExpanded}
|
|
672
705
|
>
|
|
673
706
|
<span className="flex items-start gap-3">
|
|
674
|
-
<span className="flex h-
|
|
707
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-ph-muted text-ph-ink">
|
|
675
708
|
{item.icon ?? (
|
|
676
|
-
<Plug className="h-
|
|
709
|
+
<Plug className="h-4 w-4" aria-hidden />
|
|
677
710
|
)}
|
|
678
711
|
</span>
|
|
679
712
|
<span className="min-w-0">
|
|
@@ -696,34 +729,33 @@ export function SuiteIntegrationPicker({
|
|
|
696
729
|
</span>
|
|
697
730
|
</span>
|
|
698
731
|
</button>
|
|
699
|
-
{
|
|
732
|
+
{bridgeApps.length > 0 ? (
|
|
700
733
|
<div
|
|
701
734
|
className="mt-3 flex flex-wrap items-center gap-1.5"
|
|
702
|
-
aria-label="
|
|
735
|
+
aria-label="Bridges into ShellStack apps"
|
|
703
736
|
>
|
|
704
|
-
{
|
|
705
|
-
|
|
706
|
-
|
|
737
|
+
{bridgeApps.map((app) => {
|
|
738
|
+
const mark = BRIDGE_MARK[app];
|
|
739
|
+
if (!mark) return null;
|
|
740
|
+
return (
|
|
741
|
+
<img
|
|
707
742
|
key={app}
|
|
743
|
+
src={mark.src}
|
|
744
|
+
alt=""
|
|
745
|
+
title={`Bridge in ${mark.name}`}
|
|
746
|
+
className="h-5 w-5"
|
|
708
747
|
data-test={`${dataTest}-${item.id}-app-${app}`}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
size={16}
|
|
713
|
-
label={`Works with ${SUITE_APP_MAP[app].name}`}
|
|
714
|
-
className="rounded-[4px]"
|
|
715
|
-
/>
|
|
716
|
-
</span>
|
|
717
|
-
),
|
|
718
|
-
)}
|
|
748
|
+
/>
|
|
749
|
+
);
|
|
750
|
+
})}
|
|
719
751
|
</div>
|
|
720
752
|
) : null}
|
|
721
|
-
{
|
|
753
|
+
{schemes.length > 0 ? (
|
|
722
754
|
<div
|
|
723
755
|
className="mt-3 flex flex-wrap gap-1.5"
|
|
724
756
|
aria-label="Connection methods"
|
|
725
757
|
>
|
|
726
|
-
{
|
|
758
|
+
{schemes.map((scheme) => (
|
|
727
759
|
<span
|
|
728
760
|
key={scheme}
|
|
729
761
|
className="rounded-full border border-ph-border bg-ph-muted px-2 py-1 text-[0.6875rem] font-medium text-ph-subtle"
|
|
@@ -5,6 +5,7 @@ vi.mock("next/navigation", () => ({
|
|
|
5
5
|
useRouter: () => ({ push: vi.fn() }),
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
+
import { SuiteNotificationsProvider } from "../lib/suite-notifications";
|
|
8
9
|
import { SuiteNotificationBell } from "./suite-notification-bell";
|
|
9
10
|
|
|
10
11
|
afterEach(() => {
|
|
@@ -14,21 +15,24 @@ afterEach(() => {
|
|
|
14
15
|
describe("SuiteNotificationBell", () => {
|
|
15
16
|
it("anchors the unread badge close to the bell glyph", async () => {
|
|
16
17
|
render(
|
|
17
|
-
<
|
|
18
|
+
<SuiteNotificationsProvider
|
|
18
19
|
fetchNotifications={async () => ({
|
|
19
20
|
notifications: [],
|
|
20
21
|
unread_count: 12,
|
|
21
22
|
})}
|
|
22
23
|
markRead={async () => undefined}
|
|
23
24
|
markAllRead={async () => undefined}
|
|
24
|
-
dropdownMenu={({ trigger, "aria-label": label, children }) => (
|
|
25
|
-
<div aria-label={label}>
|
|
26
|
-
{trigger}
|
|
27
|
-
{children}
|
|
28
|
-
</div>
|
|
29
|
-
)}
|
|
30
25
|
cacheKey={null}
|
|
31
|
-
|
|
26
|
+
>
|
|
27
|
+
<SuiteNotificationBell
|
|
28
|
+
dropdownMenu={({ trigger, "aria-label": label, children }) => (
|
|
29
|
+
<div aria-label={label}>
|
|
30
|
+
{trigger}
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
/>
|
|
35
|
+
</SuiteNotificationsProvider>,
|
|
32
36
|
);
|
|
33
37
|
|
|
34
38
|
const badge = await waitFor(() => {
|
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useState } from "react";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
3
|
import { Bell } from "iconoir-react";
|
|
6
4
|
|
|
7
5
|
import type { SuiteDropdownMenuComponent } from "../lib/injected";
|
|
8
|
-
import {
|
|
9
|
-
resolveSuiteNotificationHref,
|
|
10
|
-
suiteAppBaseUrl,
|
|
11
|
-
suiteAppSlugForNotificationSource,
|
|
12
|
-
} from "../lib/apps";
|
|
6
|
+
import { useSuiteNotifications } from "../lib/suite-notifications";
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|
|
8
|
+
export type {
|
|
9
|
+
SuiteNotification,
|
|
10
|
+
SuiteNotificationsResponse,
|
|
11
|
+
} from "../lib/suite-notifications";
|
|
34
12
|
|
|
35
13
|
function timeAgo(iso: string): string {
|
|
36
14
|
const then = new Date(iso).getTime();
|
|
@@ -44,155 +22,30 @@ function timeAgo(iso: string): string {
|
|
|
44
22
|
return `${Math.round(hrs / 24)}d ago`;
|
|
45
23
|
}
|
|
46
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Presentational notification bell. State, polling, caching and navigation
|
|
27
|
+
* live in SuiteNotificationsProvider — this just renders the shared context
|
|
28
|
+
* and forwards user intent back to it.
|
|
29
|
+
*/
|
|
47
30
|
export function SuiteNotificationBell({
|
|
48
|
-
fetchNotifications,
|
|
49
|
-
markRead,
|
|
50
|
-
markAllRead,
|
|
51
31
|
dropdownMenu: DropdownMenu,
|
|
52
|
-
cacheKey = "suite-notifications-cache",
|
|
53
32
|
dataTest = "suite-notifications",
|
|
54
33
|
triggerId,
|
|
55
34
|
triggerDataTest,
|
|
56
35
|
}: {
|
|
57
|
-
fetchNotifications: () => Promise<SuiteNotificationsResponse>;
|
|
58
|
-
markRead: (id: string) => Promise<unknown>;
|
|
59
|
-
markAllRead: () => Promise<unknown>;
|
|
60
36
|
dropdownMenu: SuiteDropdownMenuComponent;
|
|
61
|
-
/** sessionStorage key for stale-while-revalidate; `null` disables caching. */
|
|
62
|
-
cacheKey?: string | null;
|
|
63
37
|
dataTest?: string;
|
|
64
38
|
triggerId?: string;
|
|
65
39
|
triggerDataTest?: string;
|
|
66
40
|
}) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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]);
|
|
41
|
+
const {
|
|
42
|
+
notifications,
|
|
43
|
+
unreadCount: unread,
|
|
44
|
+
open,
|
|
45
|
+
setOpen,
|
|
46
|
+
select,
|
|
47
|
+
markAllRead,
|
|
48
|
+
} = useSuiteNotifications();
|
|
196
49
|
|
|
197
50
|
return (
|
|
198
51
|
<DropdownMenu
|
|
@@ -204,7 +57,7 @@ export function SuiteNotificationBell({
|
|
|
204
57
|
align="end"
|
|
205
58
|
panelClassName="w-80 overflow-hidden p-0"
|
|
206
59
|
open={open}
|
|
207
|
-
onOpenChange={
|
|
60
|
+
onOpenChange={setOpen}
|
|
208
61
|
trigger={
|
|
209
62
|
<span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
|
|
210
63
|
<span className="relative inline-flex h-5 w-5">
|
|
@@ -225,7 +78,7 @@ export function SuiteNotificationBell({
|
|
|
225
78
|
type="button"
|
|
226
79
|
id={`${dataTest}-mark-all`}
|
|
227
80
|
data-test={`${dataTest}-mark-all`}
|
|
228
|
-
onClick={
|
|
81
|
+
onClick={markAllRead}
|
|
229
82
|
className="text-xs font-medium text-ph-brand hover:underline"
|
|
230
83
|
>
|
|
231
84
|
Mark all read
|
|
@@ -233,17 +86,17 @@ export function SuiteNotificationBell({
|
|
|
233
86
|
) : null}
|
|
234
87
|
</div>
|
|
235
88
|
<div className="max-h-80 overflow-y-auto py-1">
|
|
236
|
-
{
|
|
89
|
+
{notifications.length === 0 ? (
|
|
237
90
|
<p className="px-3 py-6 text-center text-sm text-ph-mutedtext">
|
|
238
91
|
You're all caught up.
|
|
239
92
|
</p>
|
|
240
93
|
) : (
|
|
241
|
-
|
|
94
|
+
notifications.map((n) => (
|
|
242
95
|
<button
|
|
243
96
|
key={n.id}
|
|
244
97
|
id={`${dataTest}-item-${n.id}`}
|
|
245
98
|
data-test={`${dataTest}-item-${n.id}`}
|
|
246
|
-
onClick={() =>
|
|
99
|
+
onClick={() => select(n)}
|
|
247
100
|
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
|
|
248
101
|
>
|
|
249
102
|
<span className="flex items-center gap-2">
|