Client-side, single user live journal backed by AT Protocol
1import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
2import { Agent } from "@atproto/api";
3import {
4 displayError,
5 now,
6 makeXRPC,
7 toGraphemeSegments,
8 showMigrationDialog,
9 ALLOWED_DIDS,
10 fetchAndDisplayLatestLogs, closeMigrationDialog
11} from "#app";
12import { navigate } from "#app/router";
13import { shouldMigrate, migrateRecordsToNewLexicon } from "#app/migrations";
14
15
16const OAUTH_SCOPES =
17 "atproto repo:app.bsky.feed.post?action=create repo:space.bunniesin.micro.log?action=delete repo:space.bunniesin.log.entry?action=create";
18const ROOT = document.querySelector("main[data-currentpage]");
19
20function clientID() {
21 const isLocal = ["localhost", "127.0.0.1"].includes(window.location.hostname);
22 if (isLocal) {
23 // see https://atproto.com/specs/oauth#localhost-client-development
24 return `http://localhost?${new URLSearchParams({
25 scope: OAUTH_SCOPES,
26 redirect_uri: Object.assign(new URL(window.location.origin), {
27 hostname: "127.0.0.1",
28 }).href,
29 })}`;
30 }
31 return `https://${window.location.host}/oauth-client-metadata.json`;
32}
33
34const CLIENT_ID = clientID();
35
36let oauthClient;
37export let agent;
38
39async function beforeLogin(identifier) {
40 console.debug(
41 "[OAUTH]",
42 "performing beforeLogin check for handle",
43 identifier,
44 );
45
46 const res = await fetch(
47 makeXRPC(
48 "com.atproto.identity.resolveHandle",
49 {
50 handle: identifier,
51 },
52 "https://api.bsky.app",
53 ),
54 ).then(async (res) => await res.json());
55
56 if (!ALLOWED_DIDS.includes(res.did)) {
57 console.warn("[OAUTH]", "Unauthorized DID", res.did);
58
59 navigate("log-unauthorized");
60 return false;
61 }
62
63 return true;
64}
65
66async function setupOAuth() {
67 try {
68 oauthClient = await BrowserOAuthClient.load({
69 clientId: CLIENT_ID,
70 handleResolver: "https://bsky.social",
71 });
72
73 const result = await oauthClient.init();
74
75 if (!result) return;
76
77 const { session, state } = result;
78 if (state != null) {
79 console.debug(
80 "[OAUTH]",
81 "Authenticated",
82 session.sub,
83 `(state: ${state})`,
84 );
85 } else {
86 console.info("[OAUTH]", "Restored session", session.sub);
87 }
88
89 agent = new Agent(session);
90
91 const res = await agent.com.atproto.server.getSession();
92 if (!res.success) {
93 console.error("[OAUTH]", "Could not acquire session", res);
94 throw new Error(JSON.stringify(res));
95 }
96
97 console.info("[OAUTH]", "Agent initialized");
98 ROOT.setAttribute("data-state", "authorized");
99 if (await shouldMigrate(agent.did)) {
100 showMigrationDialog()
101 }
102 } catch (error) {
103 displayError("oauth", error);
104 }
105}
106
107async function performLogin(identifier, form) {
108 form.querySelectorAll("button").forEach((input) => {
109 input.setAttribute("aria-busy", "true");
110 input.setAttribute("disabled", true);
111 });
112
113 try {
114 if (!(await beforeLogin(identifier))) return;
115
116 await oauthClient.signIn(identifier, {
117 state: window.crypto.randomUUID(),
118 signal: new AbortController().signal,
119 });
120 } catch (err) {
121 displayError("login", err);
122 console.error(err);
123 } finally {
124 form.querySelectorAll("button").forEach((input) => {
125 input.removeAttribute("aria-busy");
126 input.removeAttribute("disabled");
127 });
128 }
129}
130
131function revokeSession() {
132 if (!agent?.did) return; // do not revoke if we aren't logged in lol
133
134 oauthClient.revoke(agent.did);
135 window.location.reload();
136}
137
138// https://stackoverflow.com/a/17980070
139function strip(html) {
140 var tmp = document.implementation.createHTMLDocument("New").body;
141 tmp.innerHTML = html;
142 return tmp.textContent || tmp.innerText || "";
143}
144
145/**
146 * @param {number} length
147 * @param {string} content
148 * @param {string} generatedTID
149 * @returns {import("@atproto/api").AppBskyEmbedExternal.External}
150 */
151function generateEmbedForContent(length, content, generatedTID) {
152 return {
153 $type: "app.bsky.embed.external",
154 external: {
155 uri: `${window.location.href}?log=${generatedTID}`,
156 title: "View in bunny log",
157 description: length > 63 ? content.substring(0, 60) + "..." : "", // Skip description because otherwise it looks weird and repetitive
158 },
159 };
160}
161
162/**
163 * @returns {Promise<import("@atproto/api").ComAtprotoRepoCreateRecord.Response> | void}
164 */
165async function createCrosspostedLog(tid, content) {
166 /** @type {string | string[]} */
167 let text = toGraphemeSegments(strip(content));
168 const embedRecord = generateEmbedForContent(length, text.join(""), tid);
169 if (text.length > 300) {
170 text = [...content.slice(0, 296), "..."];
171 }
172
173 text = text.join("");
174
175 try {
176 return await agent.com.atproto.repo.createRecord({
177 repo: agent.did,
178 collection: "app.bsky.feed.post",
179 rkey: tid,
180 validate: true,
181 record: {
182 $type: "app.bsky.feed.post",
183 text,
184 embed: embedRecord,
185 createdAt: new Date().toISOString(),
186 },
187 });
188 } catch (err) {
189 displayError("create", err);
190 console.error(err);
191 }
192}
193
194async function crosspost(content, tid) {
195 const crosspost = await createCrosspostedLog(tid, content);
196 if (!crosspost.success) throw new Error(crosspost);
197
198 await agent.com.atproto.repo.createRecord({
199 repo: agent.did,
200 collection: "space.bunniesin.log.entry",
201 rkey: tid,
202 record: {
203 $type: "space.bunniesin.log.entry",
204 content: content,
205 createdAt: new Date().toISOString(),
206 blueskyPost: {
207 uri: crosspost.data.uri,
208 cid: crosspost.data.cid,
209 },
210 },
211 });
212}
213
214async function createLog(content, form) {
215 form.querySelectorAll("button, input[type=checkbox]").forEach((input) => {
216 input.setAttribute("aria-busy", "true");
217 input.setAttribute("disabled", true);
218 });
219 const tid = now();
220
221 try {
222 if (form.querySelector("#do-crosspost").checked) {
223 await crosspost(content, tid);
224 } else {
225 await agent.com.atproto.repo.createRecord({
226 repo: agent.did,
227 collection: "space.bunniesin.log.entry",
228 rkey: tid,
229 record: {
230 $type: "space.bunniesin.log.entry",
231 content: content,
232 createdAt: new Date().toISOString(),
233 },
234 });
235 }
236
237 navigate("log-preview");
238 } catch (err) {
239 displayError("create", err);
240 console.error(err);
241 } finally {
242 form.querySelectorAll("button, input[type=checkbox]").forEach((input) => {
243 input.removeAttribute("aria-busy");
244 input.removeAttribute("disabled");
245 });
246
247 // reset form
248 form.querySelector("#log-content").value = "";
249 }
250}
251
252export async function startMigration() {
253 try {
254 await migrateRecordsToNewLexicon(agent.did);
255 console.info("[MIGRATION]", "Migration done, closing dialog and fetching entries.")
256 await fetchAndDisplayLatestLogs();
257 closeMigrationDialog();
258 } catch (err) {
259 displayError("migration", err);
260 }
261}
262
263ROOT.addEventListener("broadcast", (ev) => {
264 console.info("[ATPROTO]", "received", ev);
265 switch (ev.detail.type) {
266 case "destroy":
267 revokeSession();
268 break;
269 default:
270 console.warn("Unknown broadcasted type", ev.type);
271 break;
272 }
273});
274
275document.getElementById("log-form").addEventListener("submit", (ev) => {
276 ev.preventDefault();
277 const form = ROOT.querySelector("form#log-form");
278 const content = form.querySelector("textarea#log-content").value;
279
280 createLog(content, form);
281});
282
283document.getElementById("login-form").addEventListener("submit", (ev) => {
284 ev.preventDefault();
285 const form = ROOT.querySelector("form#login-form");
286 const identifier = form.querySelector('input[name="handle"]').value;
287 performLogin(identifier, form);
288});
289
290setupOAuth();