A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

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

at 874b7ec92bae7efba5c6870b4db59fdf4b2b69e3 236 lines 6.6 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 document.querySelector("#diffuse-loader")?.classList.add("loaded"); 165 container.classList.add("has-loaded"); 166 container.innerHTML = ` 167 <div class="diffuse"> 168 <a href="./" class="flex" style="color: inherit; text-decoration: none;"> 169 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16h64a8,8,0,0,0,7.59-5.47l14.83-44.48L163,151.43a8.07,8.07,0,0,0,4.46-4.46l14.62-36.55,44.48-14.83A8,8,0,0,0,232,88V56A16,16,0,0,0,216,40ZM117,152.57a8,8,0,0,0-4.62,4.9L98.23,200H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0l32.84,32.84Zm115-30.84V200a16,16,0,0,1-16,16H137.73a8,8,0,0,1-7.59-10.53l7.94-23.8a8,8,0,0,1,4.61-4.9l35.77-14.31,14.31-35.77a8,8,0,0,1,4.9-4.61l23.8-7.94A8,8,0,0,1,232,121.73Z"></path></svg> 170 <span style="font-size: var(--fs-base); font-weight: 700;">${error}</span> 171 </a> 172 </div> 173 `; 174 175 if (options?.throw) { 176 throw options.context ?? new Error(error); 177 } 178} 179 180//////////////////////////////////////////// 181// 🛠️ | LOADERS 182//////////////////////////////////////////// 183 184/** 185 * @param {string} uri 186 * @returns {Promise<string>} 187 */ 188async function atprotoLoader(uri) { 189 const parts = uri.replace(/at:\/\//, "").split("/"); 190 const [repo, collection, rkey] = parts; 191 192 const resolver = new LocalActorResolver({ 193 handleResolver: new XrpcHandleResolver({ 194 serviceUrl: "https://public.api.bsky.app", 195 }), 196 didDocumentResolver: new CompositeDidDocumentResolver({ 197 methods: { 198 plc: new PlcDidDocumentResolver(), 199 web: new WebDidDocumentResolver(), 200 }, 201 }), 202 }); 203 204 const identity = await resolver.resolve( 205 /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo), 206 ); 207 208 const rpc = new Client({ 209 handler: simpleFetchHandler({ service: identity.pds }), 210 }); 211 212 /** @type {any} */ 213 const { value } = await ok( 214 /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", { 215 params: { repo: identity.did, collection, rkey }, 216 }), 217 ); 218 219 if (value.html) { 220 return value.html; 221 } 222 223 if (value.uri) { 224 return loadURI(value.uri); 225 } 226 227 return ""; 228} 229 230/** 231 * @param {string} url 232 * @returns {Promise<string>} 233 */ 234async function httpLoader(url) { 235 return fetch(url).then((res) => res.text()); 236}