Client-side, single user live journal backed by AT Protocol
0

Configure Feed

Select the types of activity you want to include in your feed.

bunnylog / app.js
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});