A music player that connects to your cloud/distributed storage.
at v4 234 lines 5.9 kB view raw
1import * as URI from "fast-uri"; 2import * as TID from "@atcute/tid"; 3import { Client, ok, simpleFetchHandler } from "@atcute/client"; 4import { 5 CompositeDidDocumentResolver, 6 LocalActorResolver, 7 PlcDidDocumentResolver, 8 WebDidDocumentResolver, 9 XrpcHandleResolver, 10} from "@atcute/identity-resolver"; 11 12import * as CID from "~/common/cid.js"; 13import { effect } from "~/common/signal.js"; 14 15/** 16 * @import {SignalReader} from "~/common/signal.d.ts" 17 */ 18 19/** 20 * @typedef {{ html?: string; uri?: string; cid?: string; id: string; name: string; $type: string }} LoadableItem 21 */ 22 23/** 24 * @typedef {object} LoaderConfig 25 * @property {string} $type - The atproto $type 26 * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme") 27 * @property {() => { collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: LoadableItem[] }> }} source - The collection source 28 * @property {(item: LoadableItem) => void} render - Renders the loaded item 29 */ 30 31/** 32 * Sets up the full loader effect: reads URL params, resolves the item 33 * from the collection or creates a temporary one, ensures HTML is loaded, 34 * and calls the render callback. 35 * 36 * @param {LoaderConfig} config 37 */ 38export function createLoader(config) { 39 const docUrl = new URL(document.location.href); 40 41 const id = docUrl.searchParams.get("id"); 42 const cid = docUrl.searchParams.get("cid"); 43 const name = docUrl.searchParams.get("name"); 44 const uri = docUrl.searchParams.get("uri"); 45 const path = docUrl.searchParams.get("path"); 46 47 const containerNull = document.querySelector("#container"); 48 if (!containerNull) throw new Error("Container not found"); 49 50 const container = /** @type {HTMLDivElement} */ (containerNull); 51 52 /** @type {string | null} */ 53 let loadedCid = null; 54 55 /** @type {string | null} */ 56 let loader = null; 57 58 effect(async () => { 59 /** @type {LoadableItem | undefined} */ 60 let item = undefined; 61 62 if (path) { 63 item = { 64 $type: config.$type, 65 id: TID.now(), 66 name: "temporary", 67 uri: `diffuse://${path}`, 68 }; 69 70 loader = "path"; 71 } else if (uri) { 72 item = { 73 $type: config.$type, 74 id: TID.now(), 75 name: "temporary", 76 uri, 77 }; 78 79 loader = "uri"; 80 } else { 81 const source = config.source(); 82 const col = source.collection(); 83 if (col.state !== "loaded") return; 84 const collection = col.data; 85 86 if (id) { 87 item = collection.find((c) => c.id === id); 88 loader = "id"; 89 } else if (cid) { 90 item = collection.find((c) => c.cid === cid); 91 loader = "cid"; 92 } else if (name) { 93 item = collection.find((c) => c.name === name); 94 loader = "name"; 95 } 96 } 97 98 if (!loader) { 99 return renderError(container, "No loader specified"); 100 } else if (!item) { 101 return renderError(container, `${config.label} not found`); 102 } 103 104 // Make sure HTML is loaded when a URI is specified 105 await ensureHTML(item).catch((err) => { 106 renderError(container, `Failed to load URI: ${item.uri}`, { 107 context: err, 108 throw: true, 109 }); 110 }); 111 112 if (item.cid === loadedCid) return; 113 114 loadedCid = item.cid ?? null; 115 config.render(item); 116 }); 117} 118 119/** 120 * @param {string} uri 121 */ 122export async function loadURI(uri) { 123 const u = URI.parse(uri); 124 125 switch (u.scheme) { 126 case "at": 127 return atprotoLoader(uri); 128 case "diffuse": 129 return httpLoader(uri.replace(/^diffuse:\/\//, "")); 130 case "http": 131 case "https": 132 return httpLoader(uri); 133 default: 134 throw new Error(`Unsupported scheme: ${u.scheme}`); 135 } 136} 137 138/** 139 * Ensures the item has HTML loaded. If it has a URI but no HTML, 140 * fetches the HTML and computes the CID. 141 * 142 * @template {{ html?: string; uri?: string; cid?: string }} T 143 * @param {T} item 144 * @returns {Promise<T>} 145 */ 146export async function ensureHTML(item) { 147 if (!item.html && item.uri) { 148 const html = await loadURI(item.uri); 149 const cid = await CID.create(0x55, new TextEncoder().encode(html)); 150 151 item.html = html; 152 item.cid = cid; 153 } 154 155 return item; 156} 157 158/** 159 * @param {HTMLElement} container 160 * @param {string} error 161 * @param {{ context?: Error; throw?: boolean }} [options] 162 */ 163export function renderError(container, error, options) { 164 container.innerHTML = ` 165 <div class="diffuse"> 166 <div class="flex"> 167 <i class="ph-fill ph-warning"></i> 168 <span>${error}</span> 169 </div> 170 </div> 171 `; 172 173 if (options?.throw) { 174 throw options.context ?? new Error(error); 175 } 176} 177 178//////////////////////////////////////////// 179// 🛠️ | LOADERS 180//////////////////////////////////////////// 181 182/** 183 * @param {string} uri 184 * @returns {Promise<string>} 185 */ 186async function atprotoLoader(uri) { 187 const parts = uri.replace(/at:\/\//, "").split("/"); 188 const [repo, collection, rkey] = parts; 189 190 const resolver = new LocalActorResolver({ 191 handleResolver: new XrpcHandleResolver({ 192 serviceUrl: "https://public.api.bsky.app", 193 }), 194 didDocumentResolver: new CompositeDidDocumentResolver({ 195 methods: { 196 plc: new PlcDidDocumentResolver(), 197 web: new WebDidDocumentResolver(), 198 }, 199 }), 200 }); 201 202 const identity = await resolver.resolve( 203 /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo), 204 ); 205 206 const rpc = new Client({ 207 handler: simpleFetchHandler({ service: identity.pds }), 208 }); 209 210 /** @type {any} */ 211 const { value } = await ok( 212 /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", { 213 params: { repo: identity.did, collection, rkey }, 214 }), 215 ); 216 217 if (value.html) { 218 return value.html; 219 } 220 221 if (value.uri) { 222 return loadURI(value.uri); 223 } 224 225 return ""; 226} 227 228/** 229 * @param {string} url 230 * @returns {Promise<string>} 231 */ 232async function httpLoader(url) { 233 return fetch(url).then((res) => res.text()); 234}