1import { Client, CredentialManager } from "@atcute/client";
2import { Did } from "@atcute/lexicons";
3import {
4 createAuthorizationUrl,
5 deleteStoredSession,
6 getSession,
7 OAuthUserAgent,
8} from "@atcute/oauth-browser-client";
9import { A } from "@solidjs/router";
10import { createSignal, For, onMount, Show } from "solid-js";
11import { createStore, produce } from "solid-js/store";
12import { resolveDidDoc } from "../utils/api.js";
13import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "./dropdown.jsx";
14import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx";
15import { Modal } from "./modal.jsx";
16
17export const [sessions, setSessions] = createStore<Sessions>();
18
19const AccountDropdown = (props: { did: Did }) => {
20 const removeSession = async (did: Did) => {
21 const currentSession = agent()?.sub;
22 try {
23 const session = await getSession(did, { allowStale: true });
24 const agent = new OAuthUserAgent(session);
25 await agent.signOut();
26 } catch {
27 deleteStoredSession(did);
28 }
29 setSessions(
30 produce((accs) => {
31 delete accs[did];
32 }),
33 );
34 localStorage.setItem("sessions", JSON.stringify(sessions));
35 if (currentSession === did) setAgent(undefined);
36 };
37
38 return (
39 <MenuProvider>
40 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
41 <NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
42 <ActionMenu
43 icon="lucide--x"
44 label="Remove account"
45 onClick={() => removeSession(props.did)}
46 />
47 </DropdownMenu>
48 </MenuProvider>
49 );
50};
51
52export const AccountManager = () => {
53 const [openManager, setOpenManager] = createSignal(false);
54 const [avatars, setAvatars] = createStore<Record<Did, string>>();
55
56 onMount(async () => {
57 try {
58 await retrieveSession();
59 } catch {}
60
61 const localSessions = localStorage.getItem("sessions");
62 if (localSessions) {
63 const storedSessions: Sessions = JSON.parse(localSessions);
64 const sessionDids = Object.keys(storedSessions) as Did[];
65 sessionDids.forEach(async (did) => {
66 const doc = await resolveDidDoc(did);
67 const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://"));
68 if (alias) {
69 setSessions(did, {
70 signedIn: storedSessions[did].signedIn,
71 handle: alias.replace("at://", ""),
72 });
73 }
74 });
75 sessionDids.forEach(async (did) => {
76 const avatar = await getAvatar(did);
77 if (avatar) setAvatars(did, avatar);
78 });
79 }
80 });
81
82 const resumeSession = async (did: Did) => {
83 try {
84 localStorage.setItem("lastSignedIn", did);
85 await retrieveSession();
86 } catch {
87 const authUrl = await createAuthorizationUrl({
88 scope: import.meta.env.VITE_OAUTH_SCOPE,
89 target: { type: "account", identifier: did },
90 });
91
92 await new Promise((resolve) => setTimeout(resolve, 250));
93
94 location.assign(authUrl);
95 }
96 };
97
98 const getAvatar = async (did: Did) => {
99 const rpc = new Client({
100 handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
101 });
102 const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
103 if (res.ok) {
104 return res.data.avatar;
105 }
106 return undefined;
107 };
108
109 return (
110 <>
111 <Modal open={openManager()} onClose={() => setOpenManager(false)}>
112 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
113 <div class="mb-2 px-1 font-semibold">
114 <span>Manage accounts</span>
115 </div>
116 <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
117 <For each={Object.keys(sessions)}>
118 {(did) => (
119 <div class="flex w-full items-center justify-between">
120 <A
121 href={`/at://${did}`}
122 onClick={() => setOpenManager(false)}
123 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
124 >
125 <Show
126 when={avatars[did as Did]}
127 fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
128 >
129 <img
130 src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")}
131 class="size-6 rounded-full"
132 />
133 </Show>
134 </A>
135 <button
136 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"
137 onclick={() => resumeSession(did as Did)}
138 >
139 <span class="truncate">
140 {sessions[did]?.handle ? sessions[did].handle : did}
141 </span>
142 <Show when={did === agent()?.sub && sessions[did].signedIn}>
143 <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span>
144 </Show>
145 <Show when={!sessions[did].signedIn}>
146 <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
147 </Show>
148 </button>
149 <AccountDropdown did={did as Did} />
150 </div>
151 )}
152 </For>
153 </div>
154 <Login />
155 </div>
156 </Modal>
157 <button
158 onclick={() => setOpenManager(true)}
159 class={`flex items-center rounded-lg ${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`}
160 >
161 {agent() && avatars[agent()!.sub] ?
162 <img
163 src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")}
164 class="size-5 rounded-full"
165 />
166 : <span class="iconify lucide--circle-user-round text-lg"></span>}
167 </button>
168 </>
169 );
170};