forked from
pds.ls/pdsls
atmosphere explorer
1import { Did } from "@atcute/lexicons";
2import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
3import { A } from "@solidjs/router";
4import { createEffect, For, onMount, Show } from "solid-js";
5import { produce } from "solid-js/store";
6import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
7import { Modal } from "../components/modal.jsx";
8import { Login } from "./login.jsx";
9import { useOAuthScopeFlow } from "./scope-flow.js";
10import { ScopeSelector } from "./scope-selector.jsx";
11import { parseScopeString } from "./scope-utils.js";
12import {
13 getAvatar,
14 loadHandleForSession,
15 loadSessionsFromStorage,
16 resumeSession,
17 retrieveSession,
18 saveSessionToStorage,
19} from "./session-manager.js";
20import {
21 agent,
22 avatars,
23 openManager,
24 pendingPermissionEdit,
25 sessions,
26 setAgent,
27 setAvatars,
28 setOpenManager,
29 setPendingPermissionEdit,
30 setSessions,
31 setShowAddAccount,
32 showAddAccount,
33} from "./state.js";
34
35const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => {
36 const removeSession = async (did: Did) => {
37 const currentSession = agent()?.sub;
38 try {
39 const session = await getSession(did, { allowStale: true });
40 const agent = new OAuthUserAgent(session);
41 await agent.signOut();
42 } catch {
43 deleteStoredSession(did);
44 }
45 setSessions(
46 produce((accs) => {
47 delete accs[did];
48 }),
49 );
50 saveSessionToStorage(sessions);
51 if (currentSession === did) setAgent(undefined);
52 };
53
54 return (
55 <MenuProvider>
56 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
57 <NavMenu
58 href={`/at://${props.did}`}
59 label="Go to repo"
60 icon="lucide--user-round"
61 shortcut={agent()?.sub === props.did ? "G" : undefined}
62 />
63 <ActionMenu
64 icon="lucide--settings"
65 label="Edit permissions"
66 onClick={() => props.onEditPermissions(props.did)}
67 />
68 <ActionMenu
69 icon="lucide--x"
70 label="Remove account"
71 onClick={() => removeSession(props.did)}
72 />
73 </DropdownMenu>
74 </MenuProvider>
75 );
76};
77
78export const AccountManager = () => {
79 const getThumbnailUrl = (avatarUrl: string) => {
80 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/");
81 };
82
83 const scopeFlow = useOAuthScopeFlow({
84 beforeRedirect: (account) => resumeSession(account as Did),
85 });
86
87 createEffect(() => {
88 const pending = pendingPermissionEdit();
89 if (pending) {
90 scopeFlow.initiateWithRedirect(pending);
91 setPendingPermissionEdit(null);
92 }
93 });
94
95 const handleAccountClick = async (did: Did) => {
96 try {
97 await resumeSession(did);
98 } catch {
99 scopeFlow.initiate(did);
100 }
101 };
102
103 onMount(async () => {
104 try {
105 await retrieveSession();
106 } catch {}
107
108 const storedSessions = loadSessionsFromStorage();
109 if (storedSessions) {
110 const sessionDids = Object.keys(storedSessions) as Did[];
111 sessionDids.forEach(async (did) => {
112 await loadHandleForSession(did, storedSessions);
113 });
114 sessionDids.forEach(async (did) => {
115 const avatar = await getAvatar(did);
116 if (avatar) setAvatars(did, avatar);
117 });
118 }
119 });
120
121 return (
122 <>
123 <Modal
124 open={openManager()}
125 onClose={() => {
126 setOpenManager(false);
127 setShowAddAccount(false);
128 scopeFlow.cancel();
129 }}
130 alignTop
131 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-full max-w-sm rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 mx-3 shadow-md dark:border-neutral-700"
132 >
133 <Show when={!scopeFlow.showScopeSelector() && !showAddAccount()}>
134 <div class="mb-2 px-1 font-semibold">
135 <span>Switch account</span>
136 </div>
137 <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
138 <For each={Object.keys(sessions)}>
139 {(did) => (
140 <div class="flex w-full items-center justify-between">
141 <A
142 href={`/at://${did}`}
143 onClick={() => setOpenManager(false)}
144 class="flex shrink-0 items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
145 >
146 <Show
147 when={avatars[did as Did]}
148 fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
149 >
150 <img src={getThumbnailUrl(avatars[did as Did])} class="size-6 rounded-full" />
151 </Show>
152 </A>
153 <button
154 class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
155 onclick={() => handleAccountClick(did as Did)}
156 >
157 <span class="truncate">{sessions[did]?.handle || did}</span>
158 <Show when={did === agent()?.sub && sessions[did].signedIn}>
159 <span class="iconify lucide--circle-check shrink-0 text-blue-500 dark:text-blue-400"></span>
160 </Show>
161 <Show when={!sessions[did].signedIn}>
162 <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
163 </Show>
164 </button>
165 <AccountDropdown
166 did={did as Did}
167 onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)}
168 />
169 </div>
170 )}
171 </For>
172 </div>
173 <button
174 onclick={() => setShowAddAccount(true)}
175 class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700"
176 >
177 <span class="iconify lucide--plus"></span>
178 <span>Add account</span>
179 </button>
180 </Show>
181
182 <Show when={showAddAccount() && !scopeFlow.showScopeSelector()}>
183 <Login onCancel={() => setShowAddAccount(false)} />
184 </Show>
185
186 <Show when={scopeFlow.showScopeSelector()}>
187 <ScopeSelector
188 initialScopes={parseScopeString(
189 sessions[scopeFlow.pendingAccount()]?.grantedScopes || "",
190 )}
191 onConfirm={scopeFlow.complete}
192 onCancel={() => {
193 scopeFlow.cancel();
194 setShowAddAccount(false);
195 }}
196 />
197 </Show>
198 </Modal>
199 <button
200 onclick={() => setOpenManager(true)}
201 class={`flex items-center rounded-md ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`}
202 >
203 {agent() && avatars[agent()!.sub] ?
204 <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" />
205 : <span class="iconify lucide--circle-user-round text-lg"></span>}
206 </button>
207 </>
208 );
209};