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