forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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}