Client-side, single user live journal backed by AT Protocol
16 kB
573 lines
1import { defineNavigationHook, navigate } from "#app/router";
2import { getBacklinksCount } from "#app/microcosm";
3import { startMigration } from "#app/oauth";
4
5/** sloppy regex for matching atproto uri, they aren't compliant with rfc-3986 */
6const RE_ATURI =
7 /^at:\/\/(?<identity>[a-z0-9:]+)\/(?<collection>.+)\/(?<rkey>.+)/;
8
9/**
10 * @typedef {Object} ATStrongRef
11 * A URI with a content-hash fingerprint
12 * @property {string} cid
13 * @property {string} uri
14 */
15
16/**
17 * @typedef {Object} BunnyLogEntry
18 * Simple Microblogging Lexicon
19 * @property {string} content The primary log entry content
20 * @property {string} date Client-declared timestamp when this entry was createdAt
21 * @property {ATStrongRef} blueskyPost Reference to the cross-posted version of the log
22 */
23
24// @atcute/tid START
25const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/;
26const S32_CHAR = "234567abcdefghijklmnopqrstuvwxyz";
27
28let lastTimestamp = 0;
29let lastCurrentTime = 0;
30
31const S32_2CHAR_TABLE = (() => {
32 /** @type {string[1024]} */
33 const table = Array.from({ length: 1024 });
34 for (let i = 0; i < 1024; i++) {
35 const hi = S32_CHAR.charAt((i >> 5) & 31);
36 const lo = S32_CHAR.charAt(i & 31);
37 table[i] = hi + lo;
38 }
39 return table;
40})();
41
42/**
43 * @param {number} i
44 * @returns {string}
45 */
46const s32encode = (i) => {
47 let s = "";
48 while (i) {
49 const c = i % 32;
50 i = Math.floor(i / 32);
51 s = S32_CHAR.charAt(c) + s;
52 }
53
54 return s;
55};
56
57const random = (max) => {
58 return Math.floor(Math.random() * max);
59};
60
61const validateTID = (tid) => {
62 return tid.length === 13 && TID_RE.test(tid);
63};
64
65/**
66 * Creates a TID based off provided timestamp and clockid, with no validation.
67 * @param {number} timestamp
68 * @param {number} clockid
69 * @returns {string}
70 */
71export const createRaw = (timestamp, clockid) => {
72 return s32encode(timestamp).padStart(11, "2") + S32_2CHAR_TABLE[clockid];
73};
74
75/**
76 * Creates a TID based off provided timestamp and clockid
77 * @param {number} timestamp
78 * @param {number} clockid
79 * @returns {string}
80 */
81export const create = (timestamp, clockid) => {
82 if (timestamp < 0 || !Number.isSafeInteger(timestamp)) {
83 throw new Error(`invalid timestamp`);
84 }
85 if (clockid < 0 || clockid > 1023) {
86 throw new Error(`invalid clockid`);
87 }
88 return createRaw(timestamp, clockid);
89};
90
91/**
92 * Return a TID based on current time
93 * @returns {string}
94 */
95export const now = () => {
96 const currentTime = Date.now() * 1_000;
97 let timestamp;
98 if (currentTime === lastCurrentTime) {
99 // same time; increment to avoid collision
100 timestamp = lastTimestamp + 1;
101 } else {
102 // time changed
103 timestamp = currentTime;
104 lastCurrentTime = currentTime;
105 }
106 lastTimestamp = timestamp;
107 return createRaw(timestamp, random(1024));
108};
109
110// @atcute/tid END
111
112const TYPEAHEAD_PROVIDER = "https://typeahead.waow.tech";
113const DEFAULT_PREVIEW_HANDLE = "bunniesin.space";
114const DEFAULT_PREVIEW_DID = "did:plc:gijpvbkdbr56kazbdjhfvb3d";
115// const DEFAULT_PREVIEW_DID = "did:plc:5xgmly2j6ak2v2edj75pszeg";
116const DEFAULT_PREVIEW_DID_PDS = "https://eurosky.social";
117// const DEFAULT_PREVIEW_DID_PDS = "https://jellybaby.us-east.host.bsky.network";
118export const ALLOWED_DIDS = [
119 "did:plc:gijpvbkdbr56kazbdjhfvb3d",
120 "did:plc:5xgmly2j6ak2v2edj75pszeg",
121];
122const INSTANCE_OPERATOR_HANDLE = "bunniesin.space";
123
124/** @type {{type: string, value: string}[]} */
125const INSTANCE_OPERATOR_CONTACTS = [
126 {
127 type: "stoat",
128 value: "amybunnygirl#0122",
129 },
130 {
131 type: "irc",
132 value: "amybunny",
133 },
134 {
135 type: "bluesky",
136 value: "@bunniesin.space",
137 },
138];
139
140let previous_cursor = null;
141
142/* placeholder replacement map */
143/**
144 * Placeholders and their replacements
145 * @typedef {keyof typeof PLACEHOLDER_MAP} PlaceholderName
146 */
147const PLACEHOLDER_MAP = {
148 "unauth-op-contact-list": INSTANCE_OPERATOR_CONTACTS.map(
149 ({ type, value }) => `<li>${type}: ${value}</li>`,
150 ).join("\n"),
151 "unauth-op-contacts-msg": INSTANCE_OPERATOR_HANDLE,
152 "preview-latest-handle": DEFAULT_PREVIEW_HANDLE,
153 "log-perma-date": (date) => DATE_FORMATTER.format(date),
154};
155
156/* elements!! */
157const ROOT = document.getElementById("root");
158const POST_LIST = document.getElementById("postlist-wrapper");
159const TYPEAHEAD_ELEMENTS = document.querySelectorAll(
160 'input[type="text"].with-typeahead',
161);
162const PAGINATION_MARKER = document.getElementById("postlist--pagination-marker");
163const DATE_FORMATTER = new Intl.DateTimeFormat("en-GB", {
164 timeStyle: "long",
165 dateStyle: "short",
166});
167/** @type {HTMLDialogElement} */
168const MIGRATION_PROMPT = document.getElementById("migration-prompt")
169
170/**
171 * utf16 -> utf8 segments -> utf16 segments for processing in native js
172 * @type {string} string
173 * @type {string} [lang] - Language to use for segmenter, defaults to "en".
174 * @type {Intl.SegmenterOptions} [options] - Options to pass to `Intl.Segmenter`
175 * @returns {string[]}
176 */
177export function toGraphemeSegments(
178 string,
179 lang = "en",
180 options = { granularity: "grapheme" },
181) {
182 return [...new Intl.Segmenter(lang, options).segment(string)].map(
183 (s) => s.segment,
184 );
185}
186
187/**
188 * Initialization function for non-dynamic placeholders
189 * This works similarly to i18n, but more focused on instance-specific, static data.
190 */
191function replacePlaceholders() {
192 ROOT.querySelectorAll(".with-holder:not(.lazy)").forEach((el) => {
193 const pKeys = el.innerHTML
194 .match(/{([a-z-]+)}/g)
195 .map((k) => k.substring(1, k.length - 1));
196 let replaced = el.innerHTML;
197
198 for (const key of pKeys) {
199 if (typeof PLACEHOLDER_MAP[key] === "function") continue; // unsupported
200
201 console.info("[APP]", "Applying placeholder", key);
202 replaced = replaced.replace(`{${key}}`, PLACEHOLDER_MAP[key]);
203 }
204
205 el.innerHTML = replaced;
206 });
207}
208
209/**
210 * Replace a placeholder inside of an element, currently only works one placeholder at a time
211 * @param {HTMLElement} element
212 * @param {PlaceholderName} name
213 * @param {() => void} [data]
214 * @returns {void}
215 */
216function replacePlaceholderFor(element, name, data) {
217 if (
218 !element.classList.contains("with-holder") &&
219 !element.classList.contains("lazy")
220 )
221 return;
222 const pKeys = element.innerHTML
223 .match(/{([a-z-]+)}/g)
224 .map((k) => k.substring(1, k.length - 1));
225 let replaced = element.innerHTML;
226
227 for (const key of pKeys) {
228 if (key === name) {
229 const placeholder = PLACEHOLDER_MAP[key];
230 if (typeof placeholder === "function") {
231 console.info(
232 "[APP]",
233 "Applying placeholder",
234 name,
235 "with string",
236 `"${data}"`,
237 );
238 replaced = replaced.replace(`{${key}}`, placeholder(data));
239 } else {
240 console.info("[APP]", "Applying placeholder", name);
241 replaced = replaced.replace(`{${key}}`, placeholder);
242 }
243 }
244 }
245
246 element.innerHTML = replaced;
247}
248
249// do this asap
250replacePlaceholders();
251
252function toggleLoading() {
253 const loader = document.getElementById("loader");
254
255 loader.classList.toggle("hidden");
256}
257
258/**
259 * Gets parts of an AT URI
260 * @param {string} uri
261 * @returns {{identity: string, collection: string, rkey: string}}
262 */
263export function getATURIParts(uri) {
264 const match = RE_ATURI.exec(uri);
265 return {
266 ...(match.groups ?? { identity: null, collection: null, rkey: null }),
267 };
268}
269
270/**
271 * Makes an XRPC URL, used in tandem with {@link fetch}
272 * @param {string} nsid - NSID of the endpoint to querySelectorAll
273 * @param {Record<string, string>} params - Query parameters to pass to the querySelectorAll
274 * @param {string} [appview] - API hostname, defaults to {@link DEFAULT_PREVIEW_DID_PDS}
275 * @returns {string}
276 */
277export function makeXRPC(nsid, params, appview = DEFAULT_PREVIEW_DID_PDS) {
278 const url = new URL(`${appview}/xrpc/${nsid}`);
279 for (const [key, value] of Object.entries(params)) {
280 if (!value) continue;
281 url.searchParams.set(key, value);
282 }
283
284 return url.toString();
285}
286
287async function fetchPostsFromPreviewDID(next_cursor) {
288 console.info("[APP]", "Fetching posts from preview DID", DEFAULT_PREVIEW_DID);
289 const res = await fetch(
290 makeXRPC("com.atproto.repo.listRecords", {
291 repo: DEFAULT_PREVIEW_DID,
292 collection: "space.bunniesin.log.entry",
293 cursor: next_cursor,
294 }),
295 );
296
297 if (!res.ok) {
298 console.error("[APP]", "failed to fetch latest logs:", res.statusText);
299 displayError("fetchPreviewList", JSON.stringify(res));
300 return;
301 }
302
303 const { cursor, records } = await res.json();
304 previous_cursor = cursor;
305
306 return records.map((record) => ({
307 ...record.value,
308 rkey: getATURIParts(record.uri).rkey,
309 }));
310}
311
312/**
313 * Fetch a single post from the default preview account
314 * @param {string} rkey - The key of the record to fetch
315 * @returns {Promise<BunnyLogEntry & {rkey: string}>}
316 */
317async function fetchSinglePostFromPreviewDID(rkey) {
318 console.info("[APP]", "Fetching", rkey);
319 const res = await fetch(
320 makeXRPC("com.atproto.repo.getRecord", {
321 repo: DEFAULT_PREVIEW_DID,
322 collection: "space.bunniesin.log.entry",
323 rkey,
324 }),
325 );
326
327 if (!res.ok) {
328 console.error("[APP]", "failed to fetch log:", res.statusText);
329 displayError("fetchPreviewList", res.statusText);
330 navigate("log-preview");
331 return;
332 }
333
334 const { value, uri } = await res.json(res);
335
336 return { ...value, rkey: getATURIParts(uri).rkey };
337}
338
339/**
340 * Render a Log entry as HTML, does not sanitize content
341 * @param {BunnyLogEntry & { rkey: string }} record - A log entry
342 * @returns {Promise<HTMLDivElement>}
343 */
344async function displayLog(record) {
345 if (record["$type"] !== "space.bunniesin.log.entry")
346 throw new Error(`Invalid record type ${record["$type"]}`);
347 console.info("[APP]", "Rendering log", record.rkey);
348
349 const logElement = document.createElement("div");
350 logElement.classList.add("log");
351 const rendered_log = record.content
352 .split("\n\n")
353 .map((line) => `<p>${line.replace(/\n|\n\r/g, "<br />")}</p>`)
354 .join("");
355
356 const permalink = new URL(window.location);
357 permalink.searchParams.set("log", record.rkey);
358
359 let bsky_info = {
360 likes: null,
361 full_url: null,
362 };
363 if (record.blueskyPost) {
364 bsky_info.likes = await getBacklinksCount(
365 record.blueskyPost.uri,
366 "app.bsky.feed.like:subject.uri",
367 );
368
369 const parts = getATURIParts(record.blueskyPost.uri);
370 bsky_info.full_url = `https://mu.social/profile/${parts.identity}/post/${parts.rkey}`;
371 }
372
373 // TODO: Refactor this into a builder
374 logElement.innerHTML = `${rendered_log}
375<div class="meta"><time datetime=${record.createdAt}>${DATE_FORMATTER.format(new Date(record.createdAt))}</time>${bsky_info.likes ? ` · <span>❤️ ${bsky_info.likes}</span>` : ""}${bsky_info.full_url ? ` · <a target="_blank" href=${bsky_info.full_url}>bluesky</a>` : ""}</div>
376<button class="show-hover" onclick="navigator.clipboard.writeText('${permalink.toString()}');this.innerHTML='copied! ✨'">copy permalink</button>`;
377
378 return logElement;
379}
380
381export async function fetchAndDisplayLatestLogs(cursor) {
382 toggleLoading();
383 console.info("[APP]", "Loading latest logs");
384 if (!cursor) { POST_LIST.innerHTML = ""; }
385
386 try {
387 const logs = await fetchPostsFromPreviewDID(cursor);
388
389 for await (const log of logs) {
390 POST_LIST.appendChild(await displayLog(log));
391 }
392 } catch (err) {
393 displayError("fetchPreview", err);
394 POST_LIST.innerHTML = "<code>:(</code>";
395 } finally {
396 toggleLoading();
397 }
398}
399
400export function displayError(context, message) {
401 let errorKind;
402 switch (context) {
403 case "login":
404 errorKind = "Login error:";
405 break;
406 case "oauth":
407 errorKind = "OAuth Error:";
408 break;
409 case "create":
410 errorKind = "Error while creating micro.json:";
411 break;
412 case "fetchPreview":
413 errorKind = "Error while fetching log:";
414 break;
415 case "fetchPreviewList":
416 errorKind = "Error while fetching latest logs:";
417 break;
418 case "migration":
419 errorKind = "Migration was interrupted by an error:";
420 break;
421 default:
422 errorKind = "Unknown error:";
423 break;
424 }
425
426 alert(errorKind + " " + message);
427}
428
429async function handlePermalink() {
430 const tid = new URL(window.location).searchParams.get("log");
431 if (!tid) return; //this is not for us.
432 if (!validateTID(tid)) return;
433
434 const permalinkPage = document.getElementById("log-permalink");
435 const permalinkWrapper = permalinkPage.querySelector(".wrapper");
436
437 const title = permalinkPage.querySelector(".with-holder");
438 try {
439 const post = await fetchSinglePostFromPreviewDID(tid);
440
441 console.info("[APP]", "Opening permalink for", tid);
442
443 replacePlaceholderFor(title, "log-perma-date", new Date(post.createdAt));
444
445 permalinkWrapper.replaceChildren(await displayLog(post));
446 navigate("log-permalink");
447 } catch (err) {
448 console.error(err);
449 displayError("fetchPreview", err);
450 }
451}
452
453// from https://tangled.org/zzstoatzz.io/typeahead/blob/main/src/pages/home.ts
454// TODO: MASSIVE CODE CLEANUP, I CAN DO BETTER THAN THIS
455function registerTypeahead(element) {
456 const results = ROOT.querySelector(
457 `.typeahead-results[data-for="${element.id}"]`,
458 );
459 if (!results) return;
460
461 let timer = null;
462 element.addEventListener("input", () => {
463 clearTimeout(timer);
464 const v = element.value.trim();
465 if (v.length < 2) {
466 results.classList.remove("show");
467 return;
468 }
469 timer = setTimeout(async () => {
470 try {
471 const r = await fetch(
472 makeXRPC(
473 "app.bsky.actor.searchActorsTypeahead",
474 {
475 q: encodeURIComponent(v),
476 limit: 3,
477 },
478 TYPEAHEAD_PROVIDER,
479 ),
480 );
481 const data = await r.json();
482 const actors = data.actors || [];
483 if (actors.length === 0) {
484 results.innerHTML = '<div class="empty">no results</div>';
485 } else {
486 results.innerHTML = actors
487 .map(
488 (a) =>
489 `<div class="result" onclick="document.getElementById('${element.id}').value='${a.handle}';document.querySelector('.typeahead-results[data-for=${element.id}]').classList.remove('show')">` +
490 (a.avatar
491 ? '<img src="' + a.avatar + '" alt="">'
492 : '<div class="placeholder"></div>') +
493 '<div class="info"><div class="name">' +
494 esc(a.displayName || a.handle) +
495 "</div>" +
496 '<div class="handle">@' +
497 esc(a.handle) +
498 "</div></div></div>",
499 )
500 .join("");
501 }
502 results.classList.add("show");
503 } catch (e) {}
504 }, 200);
505 });
506 document.addEventListener("click", (e) => {
507 if (!e.target.closest(".search-wrap")) results.classList.remove("show");
508 });
509 element.addEventListener("focus", () => {
510 if (results.innerHTML) results.classList.add("show");
511 });
512
513 function esc(s) {
514 const d = document.createElement("div");
515 d.textContent = s;
516 return d.innerHTML;
517 }
518}
519
520async function setupTypeaheadElements() {
521 for (const element of TYPEAHEAD_ELEMENTS) {
522 registerTypeahead(element);
523 }
524}
525
526export function showMigrationDialog() {
527 MIGRATION_PROMPT.showModal()
528}
529
530export function disableButtonsWhileMigrating() {
531 for (const btn of MIGRATION_PROMPT.querySelectorAll("button")) {
532 btn.disabled = true;
533 btn.ariaBusy = "true";
534 }
535
536 MIGRATION_PROMPT.querySelector("button[type=submit]").innerText = "Migrating, please wait...";
537}
538
539export function closeMigrationDialog() {
540 MIGRATION_PROMPT.close();
541}
542
543defineNavigationHook("log-preview", () => {
544 previous_cursor = null;
545 fetchAndDisplayLatestLogs();
546});
547
548MIGRATION_PROMPT.querySelector("button[type=submit]").addEventListener("click", (ev) => {
549 ev.preventDefault();
550 disableButtonsWhileMigrating();
551 startMigration();
552})
553
554document.addEventListener("DOMContentLoaded", () => {
555 if (window.location.search) {
556 handlePermalink();
557 }
558 setupTypeaheadElements();
559 fetchAndDisplayLatestLogs();
560});
561
562const infiniteScrollObserver = new IntersectionObserver((entries) => {
563 if (entries[0].intersectionRatio <= 0) return;
564 console.debug("[IS]", "Marker is in view, attempting to fetch next items in list");
565
566 if (previous_cursor) {
567 fetchAndDisplayLatestLogs(previous_cursor);
568 } else {
569 console.debug("[IS]", "No more items to be loaded.")
570 }
571})
572
573infiniteScrollObserver.observe(PAGINATION_MARKER, { root: POST_LIST, rootMargin: "0px", scrollMargin: "0px", threshold: 0.25});