@ima-jin/ui 1.0.0
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/README.md +36 -0
- package/dist/index.js +1937 -0
- package/dist/index.mjs +1891 -0
- package/package.json +26 -0
- package/src/BuildInfo.tsx +20 -0
- package/src/DidShareListEditor.tsx +433 -0
- package/src/MarkdownContent.tsx +70 -0
- package/src/MarkdownEditor.tsx +79 -0
- package/src/MoneyInput.tsx +103 -0
- package/src/PayoutSetupBanner.tsx +117 -0
- package/src/acting-as.ts +21 -0
- package/src/action-sheet.tsx +118 -0
- package/src/app-launcher.tsx +298 -0
- package/src/app-shell.tsx +154 -0
- package/src/balance-badge.tsx +106 -0
- package/src/brand.ts +26 -0
- package/src/button.tsx +14 -0
- package/src/connection-picker.tsx +106 -0
- package/src/footer.tsx +39 -0
- package/src/index.ts +44 -0
- package/src/nav-bar.tsx +611 -0
- package/src/notification-bell.tsx +144 -0
- package/src/notification-provider.tsx +134 -0
- package/src/theme-init.ts +10 -0
- package/src/toast.tsx +147 -0
- package/src/use-identities.ts +69 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useNotifications } from './notification-provider';
|
|
5
|
+
|
|
6
|
+
function relativeTime(dateStr: string): string {
|
|
7
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
8
|
+
const minutes = Math.floor(diff / 60_000);
|
|
9
|
+
if (minutes < 1) return 'just now';
|
|
10
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
11
|
+
const hours = Math.floor(minutes / 60);
|
|
12
|
+
if (hours < 24) return `${hours}h ago`;
|
|
13
|
+
const days = Math.floor(hours / 24);
|
|
14
|
+
if (days === 1) return 'Yesterday';
|
|
15
|
+
return `${days}d ago`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function BellIcon({ className }: { className?: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<svg
|
|
21
|
+
className={className}
|
|
22
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
fill="none"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth={2}
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
>
|
|
31
|
+
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
|
32
|
+
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function NotificationBell() {
|
|
38
|
+
const { unreadCount, notifications, markAsRead, markAllAsRead, refresh } = useNotifications();
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
|
|
43
|
+
const handleOpen = useCallback(async () => {
|
|
44
|
+
if (!open) {
|
|
45
|
+
setOpen(true);
|
|
46
|
+
setLoading(true);
|
|
47
|
+
await refresh();
|
|
48
|
+
setLoading(false);
|
|
49
|
+
} else {
|
|
50
|
+
setOpen(false);
|
|
51
|
+
}
|
|
52
|
+
}, [open, refresh]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
function handleClickOutside(e: MouseEvent) {
|
|
57
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
58
|
+
setOpen(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
62
|
+
if (e.key === 'Escape') setOpen(false);
|
|
63
|
+
}
|
|
64
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
65
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
68
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
69
|
+
};
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
const hasUnread = notifications.some(n => !n.read);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="relative" ref={panelRef}>
|
|
76
|
+
<button
|
|
77
|
+
onClick={handleOpen}
|
|
78
|
+
className={`relative p-2 rounded-lg transition ${
|
|
79
|
+
open ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
80
|
+
}`}
|
|
81
|
+
title="Notifications"
|
|
82
|
+
aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ''}`}
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
>
|
|
85
|
+
<BellIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
|
86
|
+
{unreadCount > 0 && (
|
|
87
|
+
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[1.1rem] h-[1.1rem] flex items-center justify-center px-1 leading-none pointer-events-none">
|
|
88
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
{open && (
|
|
94
|
+
<div className="absolute right-0 mt-2 w-80 bg-gray-900 border border-gray-700 rounded-lg shadow-lg z-50 flex flex-col max-h-[24rem]">
|
|
95
|
+
{/* Header */}
|
|
96
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 shrink-0">
|
|
97
|
+
<span className="text-sm font-semibold text-white">Notifications</span>
|
|
98
|
+
{hasUnread && (
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => markAllAsRead()}
|
|
101
|
+
className="text-xs text-blue-400 hover:text-blue-300 transition"
|
|
102
|
+
>
|
|
103
|
+
Mark all as read
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Body */}
|
|
109
|
+
<div className="overflow-y-auto flex-1">
|
|
110
|
+
{loading ? (
|
|
111
|
+
<div className="px-4 py-6 text-center text-sm text-gray-400">Loading…</div>
|
|
112
|
+
) : notifications.length === 0 ? (
|
|
113
|
+
<div className="px-4 py-6 text-center text-sm text-gray-400">No notifications yet</div>
|
|
114
|
+
) : (
|
|
115
|
+
notifications.map(notification => (
|
|
116
|
+
<button
|
|
117
|
+
key={notification.id}
|
|
118
|
+
onClick={() => markAsRead(notification.id)}
|
|
119
|
+
className={`w-full text-left px-4 py-3 border-b border-gray-800 last:border-0 hover:bg-gray-800 transition flex items-start gap-3 ${
|
|
120
|
+
!notification.read ? 'bg-gray-800/50' : ''
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
{/* Unread dot — space always reserved so text aligns */}
|
|
124
|
+
<span className="shrink-0 w-2 h-2 rounded-full mt-1.5 flex items-center justify-center">
|
|
125
|
+
{!notification.read && (
|
|
126
|
+
<span className="block w-2 h-2 rounded-full bg-blue-500" aria-hidden="true" />
|
|
127
|
+
)}
|
|
128
|
+
</span>
|
|
129
|
+
<div className="flex-1 min-w-0">
|
|
130
|
+
<p className="text-sm font-medium text-white truncate">{notification.title}</p>
|
|
131
|
+
{notification.body && (
|
|
132
|
+
<p className="text-xs text-gray-400 mt-0.5 line-clamp-2">{notification.body}</p>
|
|
133
|
+
)}
|
|
134
|
+
<p className="text-xs text-gray-500 mt-1">{relativeTime(notification.createdAt)}</p>
|
|
135
|
+
</div>
|
|
136
|
+
</button>
|
|
137
|
+
))
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useToast } from './toast';
|
|
5
|
+
|
|
6
|
+
export interface Notification {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
urgency?: 'normal' | 'urgent';
|
|
11
|
+
read: boolean;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotificationContextValue {
|
|
16
|
+
unreadCount: number;
|
|
17
|
+
notifications: Notification[];
|
|
18
|
+
markAsRead: (id: string) => Promise<void>;
|
|
19
|
+
markAllAsRead: () => Promise<void>;
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
const emptyValue: NotificationContextValue = {
|
|
26
|
+
unreadCount: 0,
|
|
27
|
+
notifications: [],
|
|
28
|
+
markAsRead: async () => {},
|
|
29
|
+
markAllAsRead: async () => {},
|
|
30
|
+
refresh: async () => {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
|
34
|
+
const notifyUrl = process.env.NEXT_PUBLIC_NOTIFY_URL;
|
|
35
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
36
|
+
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
37
|
+
const hasWarnedRef = useRef(false);
|
|
38
|
+
const lastCountRef = useRef(-1); // -1 = not yet initialised, skip toast on first load
|
|
39
|
+
const lastNewIdRef = useRef<string | null>(null);
|
|
40
|
+
const { toast } = useToast();
|
|
41
|
+
|
|
42
|
+
// Keep toast functions in a ref so fetchAndMaybeToast doesn't need them as deps
|
|
43
|
+
const toastRef = useRef(toast);
|
|
44
|
+
toastRef.current = toast;
|
|
45
|
+
|
|
46
|
+
const fetchAndMaybeToast = useCallback(async () => {
|
|
47
|
+
if (!notifyUrl) return;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${notifyUrl}/api/notifications/unread`, { credentials: 'include' });
|
|
50
|
+
if (!res.ok) return;
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
const count: number = data.count ?? 0;
|
|
53
|
+
|
|
54
|
+
if (lastCountRef.current >= 0 && count > lastCountRef.current) {
|
|
55
|
+
// New notifications arrived — fetch the latest one and toast
|
|
56
|
+
try {
|
|
57
|
+
const listRes = await fetch(`${notifyUrl}/api/notifications?limit=1`, { credentials: 'include' });
|
|
58
|
+
if (listRes.ok) {
|
|
59
|
+
const listData = await listRes.json();
|
|
60
|
+
const latest: Notification | undefined = listData.notifications?.[0] ?? listData[0];
|
|
61
|
+
if (latest && latest.id !== lastNewIdRef.current) {
|
|
62
|
+
lastNewIdRef.current = latest.id;
|
|
63
|
+
if (latest.urgency === 'urgent') {
|
|
64
|
+
toastRef.current.warning(latest.title);
|
|
65
|
+
} else {
|
|
66
|
+
toastRef.current.info(latest.title);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lastCountRef.current = count;
|
|
74
|
+
setUnreadCount(count);
|
|
75
|
+
} catch {
|
|
76
|
+
if (!hasWarnedRef.current) {
|
|
77
|
+
console.warn('[notifications] notify service unavailable — bell will show 0 unread');
|
|
78
|
+
hasWarnedRef.current = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, [notifyUrl]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!notifyUrl) return;
|
|
85
|
+
fetchAndMaybeToast();
|
|
86
|
+
const interval = setInterval(fetchAndMaybeToast, 30_000);
|
|
87
|
+
return () => clearInterval(interval);
|
|
88
|
+
}, [notifyUrl, fetchAndMaybeToast]);
|
|
89
|
+
|
|
90
|
+
const refresh = useCallback(async () => {
|
|
91
|
+
if (!notifyUrl) return;
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(`${notifyUrl}/api/notifications?limit=20`, { credentials: 'include' });
|
|
94
|
+
if (!res.ok) return;
|
|
95
|
+
const data = await res.json();
|
|
96
|
+
setNotifications(data.notifications ?? data ?? []);
|
|
97
|
+
} catch {}
|
|
98
|
+
}, [notifyUrl]);
|
|
99
|
+
|
|
100
|
+
const markAsRead = useCallback(async (id: string) => {
|
|
101
|
+
if (!notifyUrl) return;
|
|
102
|
+
try {
|
|
103
|
+
await fetch(`${notifyUrl}/api/notifications/${id}/read`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
credentials: 'include',
|
|
106
|
+
});
|
|
107
|
+
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
|
108
|
+
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
109
|
+
} catch {}
|
|
110
|
+
}, [notifyUrl]);
|
|
111
|
+
|
|
112
|
+
const markAllAsRead = useCallback(async () => {
|
|
113
|
+
if (!notifyUrl) return;
|
|
114
|
+
try {
|
|
115
|
+
await fetch(`${notifyUrl}/api/notifications/read-all`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
credentials: 'include',
|
|
118
|
+
});
|
|
119
|
+
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
|
120
|
+
setUnreadCount(0);
|
|
121
|
+
lastCountRef.current = 0;
|
|
122
|
+
} catch {}
|
|
123
|
+
}, [notifyUrl]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<NotificationContext.Provider value={{ unreadCount, notifications, markAsRead, markAllAsRead, refresh }}>
|
|
127
|
+
{children}
|
|
128
|
+
</NotificationContext.Provider>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function useNotifications(): NotificationContextValue {
|
|
133
|
+
return useContext(NotificationContext) ?? emptyValue;
|
|
134
|
+
}
|
package/src/toast.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useReducer, useCallback, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
6
|
+
|
|
7
|
+
interface Toast {
|
|
8
|
+
id: string;
|
|
9
|
+
type: ToastType;
|
|
10
|
+
message: string;
|
|
11
|
+
sticky: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Action =
|
|
15
|
+
| { type: 'ADD'; toast: Toast }
|
|
16
|
+
| { type: 'REMOVE'; id: string };
|
|
17
|
+
|
|
18
|
+
function reducer(state: Toast[], action: Action): Toast[] {
|
|
19
|
+
switch (action.type) {
|
|
20
|
+
case 'ADD':
|
|
21
|
+
return [...state, action.toast].slice(-5);
|
|
22
|
+
case 'REMOVE':
|
|
23
|
+
return state.filter((t) => t.id !== action.id);
|
|
24
|
+
default:
|
|
25
|
+
return state;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ToastContextValue {
|
|
30
|
+
toast: {
|
|
31
|
+
success: (message: string) => void;
|
|
32
|
+
error: (message: string) => void;
|
|
33
|
+
warning: (message: string) => void;
|
|
34
|
+
info: (message: string) => void;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
39
|
+
|
|
40
|
+
const BORDER_COLORS: Record<ToastType, string> = {
|
|
41
|
+
success: 'border-l-green-500',
|
|
42
|
+
error: 'border-l-red-500',
|
|
43
|
+
warning: 'border-l-amber-500',
|
|
44
|
+
info: 'border-l-blue-500',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DURATIONS: Record<ToastType, number> = {
|
|
48
|
+
success: 4000,
|
|
49
|
+
error: 0,
|
|
50
|
+
warning: 6000,
|
|
51
|
+
info: 4000,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function AutoDismiss({
|
|
55
|
+
id,
|
|
56
|
+
duration,
|
|
57
|
+
onDismiss,
|
|
58
|
+
}: {
|
|
59
|
+
id: string;
|
|
60
|
+
duration: number;
|
|
61
|
+
onDismiss: (id: string) => void;
|
|
62
|
+
}) {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const timer = setTimeout(() => onDismiss(id), duration);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [id, duration, onDismiss]);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ToastItem({
|
|
71
|
+
toast,
|
|
72
|
+
onDismiss,
|
|
73
|
+
}: {
|
|
74
|
+
toast: Toast;
|
|
75
|
+
onDismiss: () => void;
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={`flex items-start gap-3 bg-gray-900 text-white px-4 py-3 rounded-lg shadow-lg border-l-4 ${BORDER_COLORS[toast.type]} min-w-[280px] max-w-sm`}
|
|
80
|
+
style={{ animation: 'toastSlideIn 0.2s ease-out' }}
|
|
81
|
+
>
|
|
82
|
+
<p className="flex-1 text-sm">{toast.message}</p>
|
|
83
|
+
<button
|
|
84
|
+
onClick={onDismiss}
|
|
85
|
+
className="text-gray-400 hover:text-white transition-colors shrink-0 text-lg leading-none"
|
|
86
|
+
aria-label="Dismiss"
|
|
87
|
+
>
|
|
88
|
+
×
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
95
|
+
const [toasts, dispatch] = useReducer(reducer, []);
|
|
96
|
+
|
|
97
|
+
const dismiss = useCallback((id: string) => {
|
|
98
|
+
dispatch({ type: 'REMOVE', id });
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const add = useCallback((type: ToastType, message: string) => {
|
|
102
|
+
const id =
|
|
103
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
104
|
+
? crypto.randomUUID()
|
|
105
|
+
: String(Date.now() + Math.random());
|
|
106
|
+
const sticky = type === 'error';
|
|
107
|
+
dispatch({ type: 'ADD', toast: { id, type, message, sticky } });
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const toast = {
|
|
111
|
+
success: (message: string) => add('success', message),
|
|
112
|
+
error: (message: string) => add('error', message),
|
|
113
|
+
warning: (message: string) => add('warning', message),
|
|
114
|
+
info: (message: string) => add('info', message),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<ToastContext.Provider value={{ toast }}>
|
|
119
|
+
{children}
|
|
120
|
+
<div
|
|
121
|
+
className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 items-end"
|
|
122
|
+
aria-live="polite"
|
|
123
|
+
>
|
|
124
|
+
<style>{`
|
|
125
|
+
@keyframes toastSlideIn {
|
|
126
|
+
from { transform: translateX(calc(100% + 1rem)); opacity: 0; }
|
|
127
|
+
to { transform: translateX(0); opacity: 1; }
|
|
128
|
+
}
|
|
129
|
+
`}</style>
|
|
130
|
+
{toasts.map((t) => (
|
|
131
|
+
<React.Fragment key={t.id}>
|
|
132
|
+
{!t.sticky && DURATIONS[t.type] > 0 && (
|
|
133
|
+
<AutoDismiss id={t.id} duration={DURATIONS[t.type]} onDismiss={dismiss} />
|
|
134
|
+
)}
|
|
135
|
+
<ToastItem toast={t} onDismiss={() => dismiss(t.id)} />
|
|
136
|
+
</React.Fragment>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</ToastContext.Provider>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useToast(): ToastContextValue {
|
|
144
|
+
const ctx = useContext(ToastContext);
|
|
145
|
+
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
|
146
|
+
return ctx;
|
|
147
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { getActingAs, setActingAs } from './acting-as';
|
|
5
|
+
|
|
6
|
+
export interface GroupIdentity {
|
|
7
|
+
groupDid: string;
|
|
8
|
+
role: string;
|
|
9
|
+
scope: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
handle: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IdentityConfig {
|
|
15
|
+
enabledServices: string[];
|
|
16
|
+
landingService: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useIdentities(authUrl: string | null, profileUrl?: string | null): {
|
|
20
|
+
identities: GroupIdentity[];
|
|
21
|
+
loading: boolean;
|
|
22
|
+
activeIdentity: string | null;
|
|
23
|
+
activeConfig: IdentityConfig | null;
|
|
24
|
+
setActiveIdentity: (did: string | null) => void;
|
|
25
|
+
} {
|
|
26
|
+
const [identities, setIdentities] = useState<GroupIdentity[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [activeIdentity, setActiveIdentityState] = useState<string | null>(null);
|
|
29
|
+
const [activeConfig, setActiveConfig] = useState<IdentityConfig | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setActiveIdentityState(getActingAs());
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!authUrl) return;
|
|
37
|
+
setLoading(true);
|
|
38
|
+
fetch(`${authUrl}/api/groups`, { credentials: 'include' })
|
|
39
|
+
.then((r) => r.ok ? r.json() : null)
|
|
40
|
+
.then((data) => {
|
|
41
|
+
if (Array.isArray(data)) setIdentities(data);
|
|
42
|
+
else if (data?.groups) setIdentities(data.groups);
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {})
|
|
45
|
+
.finally(() => setLoading(false));
|
|
46
|
+
}, [authUrl]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const configBase = profileUrl;
|
|
50
|
+
if (!configBase || !activeIdentity) {
|
|
51
|
+
setActiveConfig(null);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
fetch(`${configBase}/api/forest/${encodeURIComponent(activeIdentity)}/config/public`)
|
|
55
|
+
.then((r) => r.ok ? r.json() : null)
|
|
56
|
+
.then((data) => {
|
|
57
|
+
if (data) setActiveConfig(data as IdentityConfig);
|
|
58
|
+
})
|
|
59
|
+
.catch(() => {});
|
|
60
|
+
}, [authUrl, profileUrl, activeIdentity]);
|
|
61
|
+
|
|
62
|
+
function setActiveIdentity(did: string | null) {
|
|
63
|
+
setActingAs(did);
|
|
64
|
+
setActiveIdentityState(did);
|
|
65
|
+
window.location.reload();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { identities, loading, activeIdentity, activeConfig, setActiveIdentity };
|
|
69
|
+
}
|