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