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