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

Configure Feed

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

chore: new atproto + rocksky setup

+405 -335
+2 -1
.env
··· 1 - ATPROTO_CLIENT_ID=https://cimd-service.fly.dev/clients/bafyreidf2esqfai4xh2osfrq6oekkkvburcjvomebn4age7hks7teb77fi 1 + ATPROTO_CLIENT_ID=https://cimd-service.fly.dev/clients/bafyreiafqw2fao73uzdvg7rzvvcq7d4z5fvjhqc5qcqo6yzqq3p5dh6j2y 2 + ROCKSKY_ATPROTO_CLIENT_ID=https://cimd-service.fly.dev/clients/bafyreihmrs2hsh5fp535lbidz4yt3f63pyh42d6gwpchf3vrr2kxie3cpu 2 3 # DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+16 -16
.zed/settings.json
··· 3 3 "deno": { 4 4 "settings": { 5 5 "deno": { 6 - "enable": true 7 - } 8 - } 6 + "enable": true, 7 + }, 8 + }, 9 9 }, 10 10 "json-language-server": { 11 11 "settings": { 12 12 "json": { 13 13 "schemas": [ 14 14 { 15 - "fileMatch": ["deno.json"], 16 - "url": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json" 15 + "fileMatch": ["deno.json", "deno.jsonc"], 16 + "url": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", 17 17 }, 18 18 { 19 19 "fileMatch": ["package.json"], 20 - "url": "http://json.schemastore.org/package" 21 - } 22 - ] 23 - } 24 - } 25 - } 20 + "url": "http://json.schemastore.org/package", 21 + }, 22 + ], 23 + }, 24 + }, 25 + }, 26 26 }, 27 27 "languages": { 28 28 "JavaScript": { 29 29 "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"], 30 - "formatter": "language_server" 30 + "formatter": "language_server", 31 31 }, 32 32 "TypeScript": { 33 33 "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"], 34 - "formatter": "language_server" 34 + "formatter": "language_server", 35 35 }, 36 36 "TSX": { 37 37 "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"], 38 - "formatter": "language_server" 39 - } 40 - } 38 + "formatter": "language_server", 39 + }, 40 + }, 41 41 }
+20
src/components/configurator/output/element.js
··· 408 408 localStorage.setItem(`${STORAGE_PREFIX}/selected/id`, id); 409 409 await this.#selectOutput(id); 410 410 }; 411 + 412 + /** 413 + * @param {string} label 414 + * @returns {Promise<{ id: string, label: string, element: OutputElement }>} 415 + */ 416 + waitForOption = (label) => { 417 + return new Promise((resolve) => { 418 + const check = async () => { 419 + const opt = (await this.options()).find((o) => o.label === label); 420 + if (opt) { 421 + observer.disconnect(); 422 + resolve(opt); 423 + } 424 + }; 425 + 426 + const observer = new MutationObserver(check); 427 + observer.observe(this, { childList: true }); 428 + check(); 429 + }); 430 + }; 411 431 } 412 432 413 433 export default OutputConfigurator;
+1
src/components/configurator/output/types.d.ts
··· 10 10 loadSelected: () => Promise<void>; 11 11 options: () => Promise<Array<OutputOption<ElementType>>>; 12 12 select: (id: string) => Promise<void>; 13 + waitForOption: (label: string) => Promise<OutputOption<ElementType>>; 13 14 14 15 /** Output-element ids that have been selected earlier. */ 15 16 activated: SignalReader<Set<string>>;
+4
src/components/orchestrator/output/element.js
··· 111 111 return this.outputConfigurator.selected.bind(this.outputConfigurator); 112 112 } 113 113 114 + get waitForOption() { 115 + return this.outputConfigurator.waitForOption.bind(this.outputConfigurator); 116 + } 117 + 114 118 // RENDER 115 119 116 120 /**
+9 -6
src/components/output/raw/atproto/oauth.js
··· 1 1 import { configureOAuth } from "@atcute/oauth-browser-client"; 2 2 3 - import metadata from "../../../../oauth-client-metadata.json" with { 3 + import metadata from "./oauth-client-metadata.json" with { 4 4 type: "json", 5 5 }; 6 6 ··· 29 29 30 30 const STORAGE_KEY = "diffuse/output/raw/atproto/did"; 31 31 const SCOPE = metadata.scope; 32 + const STORAGE_NAME = "diffuse/output/raw/atproto/atcute/oauth"; 33 + const CLIENT_KEY = "diffuse/output/raw/atproto"; 32 34 33 35 // CONFIGURE 34 36 // ========= ··· 55 57 client_id, 56 58 redirect_uri, 57 59 }, 60 + storageName: STORAGE_NAME, 58 61 identityResolver: new LocalActorResolver({ 59 62 handleResolver: new XrpcHandleResolver({ 60 63 serviceUrl: "https://public.api.bsky.app", ··· 80 83 export async function login(handle) { 81 84 const location = globalThis.location; 82 85 83 - sessionStorage.setItem( 84 - "oauth/callback/redirect_path", 85 - location.pathname + location.search, 86 - ); 86 + sessionStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); 87 + sessionStorage.setItem("oauth/pending-client", CLIENT_KEY); 87 88 88 89 const authUrl = await createAuthorizationUrl({ 89 90 target: { type: "account", identifier: /** @type {any} */ (handle) }, ··· 109 110 // so params arrive in the URL hash, not the query string) 110 111 const params = new URLSearchParams(location.hash.slice(1)); 111 112 112 - if (params.has("code")) { 113 + if (params.has("code") && sessionStorage.getItem("oauth/pending-client") === CLIENT_KEY) { 114 + sessionStorage.removeItem("oauth/pending-client"); 115 + 113 116 const result = await finalizeAuthorization(params); 114 117 115 118 // Clean up URL (remove fragment containing OAuth params)
+107 -126
src/components/supplement/rocksky/element.js
··· 1 - import { md5 } from "@noble/hashes/legacy.js"; 2 - import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; 3 - 4 - import { getSession } from "@atcute/oauth-browser-client"; 1 + import { now as tidNow } from "@atcute/tid"; 5 2 6 3 import { BroadcastableDiffuseElement, defineElement } from "~/common/element.js"; 7 - import { computed, signal } from "~/common/signal.js"; 4 + import { signal } from "~/common/signal.js"; 5 + 6 + import { 7 + clearStoredSession, 8 + DID_STORAGE_KEY, 9 + getSession, 10 + login, 11 + logout, 12 + OAuthUserAgent, 13 + restoreOrFinalize, 14 + } from "./oauth.js"; 8 15 9 16 /** 10 17 * @import {Track} from "~/definitions/types.d.ts" ··· 15 22 // CONSTANTS 16 23 //////////////////////////////////////////// 17 24 18 - const ROCKSKY_API_URL = "https://audioscrobbler.rocksky.app/2.0/"; 19 - const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; 20 25 const STORAGE_KEY = "diffuse/supplement/rocksky/session"; 21 26 22 - const DEFAULT_API_KEY = "d21bdb464bd5e92c4dbbe814a5a9a8a4"; 23 - const DEFAULT_API_SECRET = "4a9d15e43ad1623ee7f9dc6b12d6ba08"; 24 - 25 27 //////////////////////////////////////////// 26 28 // ELEMENT 27 29 //////////////////////////////////////////// ··· 31 33 */ 32 34 class RockskyScrobbler extends BroadcastableDiffuseElement { 33 35 static NAME = "diffuse/supplement/rocksky"; 34 - 35 - get #apiKey() { 36 - return this.getAttribute("api-key") ?? DEFAULT_API_KEY; 37 - } 38 - 39 - get #apiSecret() { 40 - return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET; 41 - } 42 36 43 37 // SIGNALS 44 38 45 39 #handle = signal(/** @type {string | null} */ (null)); 46 - #sessionKey = signal(/** @type {string | null} */ (null)); 40 + #connected = signal(false); 47 41 #isAuthenticating = signal(false); 48 42 49 43 // STATE 50 44 51 45 handle = this.#handle.get; 52 - isAuthenticated = computed(() => this.#sessionKey.value !== null); 46 + isAuthenticated = this.#connected.get; 53 47 isAuthenticating = this.#isAuthenticating.get; 54 48 55 49 // LIFECYCLE ··· 63 57 scrobble: { strategy: "leaderOnly", fn: this.scrobble }, 64 58 65 59 setHandle: { strategy: "replicate", fn: this.#handle.set }, 66 - setSession: { strategy: "replicate", fn: this.#sessionKey.set }, 60 + setConnected: { strategy: "replicate", fn: this.#connected.set }, 67 61 }); 68 62 69 63 if (actions) { ··· 71 65 this.scrobble = actions.scrobble; 72 66 73 67 this.#handle.set = actions.setHandle; 74 - this.#sessionKey.set = actions.setSession; 68 + this.#connected.set = actions.setConnected; 75 69 } 76 70 } 77 71 ··· 83 77 async #tryRestore() { 84 78 await this.whenConnected(); 85 79 80 + try { 81 + const session = await restoreOrFinalize(); 82 + 83 + if (session) { 84 + const did = session.info.sub; 85 + 86 + if (await this.isLeader()) { 87 + this.#connected.set(true); 88 + this.#handle.set(did); 89 + } else { 90 + this.#connected.value = true; 91 + this.#handle.value = did; 92 + } 93 + 94 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ did })); 95 + return; 96 + } 97 + } catch (err) { 98 + console.warn("Rocksky: Failed to restore/finalize session", err); 99 + } 100 + 101 + // Restore previously stored connection state. 86 102 const stored = localStorage.getItem(STORAGE_KEY); 87 103 88 104 if (stored) { 89 105 try { 90 - const { key, name: handle } = JSON.parse(stored); 106 + const { did } = JSON.parse(stored); 107 + 91 108 if (await this.isLeader()) { 92 - this.#sessionKey.set(key); 93 - this.#handle.set(handle); 109 + this.#connected.set(true); 110 + this.#handle.set(did); 94 111 } else { 95 - this.#sessionKey.value = key; 96 - this.#handle.value = handle; 112 + this.#connected.value = true; 113 + this.#handle.value = did; 97 114 } 98 115 } catch { 99 116 localStorage.removeItem(STORAGE_KEY); ··· 104 121 // AUTH 105 122 106 123 /** 107 - * Sign in to Rocksky using the existing AT Protocol session. 108 - * Exchanges the AT Protocol access token for a Rocksky audioscrobbler session key. 124 + * Connect to Rocksky by initiating the AT Protocol OAuth flow for the given handle. 125 + * Navigates the browser away to the authorization server. 126 + * 127 + * @param {string} handle 109 128 */ 110 - async signIn() { 111 - const did = localStorage.getItem(ATPROTO_DID_KEY); 112 - if (!did) { 113 - console.warn("Rocksky: No AT Protocol session found"); 114 - return; 115 - } 116 - 129 + async signIn(handle) { 117 130 this.#isAuthenticating.set(true); 118 131 119 132 try { 120 - const session = await getSession( 121 - /** @type {`did:${string}:${string}`} */ (did), 122 - ); 123 - const accessToken = session.token.access; 124 - 125 - const data = await this.#call("auth.getMobileSession", { 126 - username: did, 127 - password: accessToken, 128 - }); 129 - this.#setSession(data.session); 130 - } catch (err) { 131 - console.warn("Rocksky: Failed to authenticate", err); 132 - throw err; 133 + await login(handle); 133 134 } finally { 134 135 this.#isAuthenticating.set(false); 135 136 } 136 137 } 137 138 138 139 /** 139 - * Clear the stored session. 140 + * Disconnect from Rocksky. 140 141 */ 141 142 signOut() { 142 - this.#sessionKey.set(null); 143 + const did = localStorage.getItem(DID_STORAGE_KEY); 144 + 145 + if (did) { 146 + getSession(/** @type {`did:${string}:${string}`} */ (did)) 147 + .then((session) => logout(new OAuthUserAgent(session))) 148 + .catch(() => clearStoredSession()); 149 + } else { 150 + clearStoredSession(); 151 + } 152 + 153 + this.#connected.set(false); 143 154 this.#handle.set(null); 144 155 localStorage.removeItem(STORAGE_KEY); 145 156 } 146 157 147 - /** @param {{ key: string, name: string }} session */ 148 - #setSession({ key, name: handle }) { 149 - this.#sessionKey.set(key); 150 - this.#handle.set(handle); 151 - localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle })); 152 - } 153 - 154 158 // SCROBBLE ACTIONS 155 159 156 160 /** 157 - * @param {Track} track 161 + * @param {Track} _track 158 162 */ 159 - async nowPlaying(track) { 160 - const tags = track.tags ?? {}; 161 - /** @type {Record<string, string>} */ 162 - const params = {}; 163 - 164 - if (tags.title) params.track = tags.title; 165 - if (tags.artist) params.artist = tags.artist; 166 - if (tags.album) params.album = tags.album; 167 - if (tags.albumartist) params.albumArtist = tags.albumartist; 168 - if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 169 - if (track.stats?.duration != null) { 170 - params.duration = String(Math.round(track.stats.duration / 1000)); 171 - } 172 - 173 - return this.#authenticatedCall("track.updateNowPlaying", params); 163 + // deno-lint-ignore no-unused-vars 164 + async nowPlaying(_track) { 165 + // Rocksky has no now-playing PDS record type; scrobbles are the source of truth. 174 166 } 175 167 176 168 /** ··· 178 170 * @param {number} startedAt Unix timestamp in milliseconds 179 171 */ 180 172 async scrobble(track, startedAt) { 181 - const tags = track.tags ?? {}; 182 - /** @type {Record<string, string>} */ 183 - const params = { 184 - timestamp: String(Math.floor(startedAt / 1000)), 185 - }; 173 + if (!this.#connected.value) return; 186 174 187 - if (tags.title) params.track = tags.title; 188 - if (tags.artist) params.artist = tags.artist; 189 - if (tags.album) params.album = tags.album; 190 - if (tags.albumartist) params.albumArtist = tags.albumartist; 191 - if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 192 - if (track.stats?.duration != null) { 193 - params.duration = String(Math.round(track.stats.duration / 1000)); 194 - } 175 + const did = localStorage.getItem(DID_STORAGE_KEY); 176 + if (!did) return; 195 177 196 - return this.#authenticatedCall("track.scrobble", params); 197 - } 178 + const session = await getSession(/** @type {`did:${string}:${string}`} */ (did)); 179 + const agent = new OAuthUserAgent(session); 198 180 199 - // API 181 + const tags = track.tags ?? {}; 200 182 201 - /** 202 - * @param {Record<string, string>} params 203 - * @returns {string} MD5 hex digest 204 - */ 205 - #sign(params) { 206 - const str = Object.keys(params) 207 - .sort() 208 - .map((k) => k + params[k]) 209 - .join(""); 210 - return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret))); 211 - } 183 + // All five fields are required by the app.rocksky.scrobble lexicon. 184 + if ( 185 + !tags.title || 186 + !tags.artist || 187 + !tags.album || 188 + !tags.albumartist || 189 + track.stats?.duration == null 190 + ) return; 212 191 213 - /** 214 - * @param {string} method 215 - * @param {Record<string, string>} [params] 216 - * @returns {Promise<any>} 217 - */ 218 - async #call(method, params = {}) { 219 - const allParams = { ...params, api_key: this.#apiKey, method }; 220 - const api_sig = this.#sign(allParams); 221 - const body = new URLSearchParams({ ...allParams, api_sig, format: "json" }); 192 + /** @type {Record<string, unknown>} */ 193 + const record = { 194 + $type: "app.rocksky.scrobble", 195 + title: tags.title, 196 + artist: tags.artist, 197 + album: tags.album, 198 + albumArtist: tags.albumartist, 199 + duration: Math.round(track.stats.duration / 1000), // seconds 200 + }; 222 201 223 - const response = await fetch(ROCKSKY_API_URL, { method: "POST", body }); 224 - const data = await response.json(); 202 + if (tags.track?.no != null) record.trackNumber = tags.track.no; 203 + if (tags.disk?.no != null) record.discNumber = tags.disk.no; 225 204 226 - if (data.error) { 227 - throw new Error(`rocksky error ${data.error}: ${data.message}`); 228 - } 205 + record.createdAt = new Date(startedAt).toISOString(); 229 206 230 - return data; 231 - } 207 + const response = await agent.handle("/xrpc/com.atproto.repo.putRecord", { 208 + method: "POST", 209 + headers: { "Content-Type": "application/json" }, 210 + body: JSON.stringify({ 211 + repo: did, 212 + collection: "app.rocksky.scrobble", 213 + rkey: tidNow(), 214 + record, 215 + validate: false, 216 + }), 217 + }); 232 218 233 - /** 234 - * @param {string} method 235 - * @param {Record<string, string>} [params] 236 - * @returns {Promise<any>} 237 - */ 238 - async #authenticatedCall(method, params = {}) { 239 - const sk = this.#sessionKey.value; 240 - if (!sk) throw new Error("Not authenticated with Rocksky"); 241 - return this.#call(method, { ...params, sk }); 219 + if (!response.ok) { 220 + const error = await response.json().catch(() => ({})); 221 + throw new Error(`rocksky: scrobble failed ${response.status}: ${error.message ?? ""}`); 222 + } 242 223 } 243 224 } 244 225
+12
src/components/supplement/rocksky/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://elements.diffuse.sh/latest/components/supplement/rocksky/oauth-client-metadata.json", 3 + "client_name": "Rocksky | Diffuse", 4 + "client_uri": "https://elements.diffuse.sh", 5 + "redirect_uris": ["https://elements.diffuse.sh/oauth/callback"], 6 + "scope": "atproto repo?collection=app.rocksky.scrobble", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+156
src/components/supplement/rocksky/oauth.js
··· 1 + import { 2 + configureOAuth, 3 + createAuthorizationUrl, 4 + deleteStoredSession, 5 + finalizeAuthorization, 6 + getSession, 7 + OAuthUserAgent, 8 + } from "@atcute/oauth-browser-client"; 9 + 10 + import { 11 + CompositeDidDocumentResolver, 12 + LocalActorResolver, 13 + PlcDidDocumentResolver, 14 + WebDidDocumentResolver, 15 + XrpcHandleResolver, 16 + } from "@atcute/identity-resolver"; 17 + 18 + import metadata from "./oauth-client-metadata.json" with { 19 + type: "json", 20 + }; 21 + 22 + /** 23 + * @import {Session} from "@atcute/oauth-browser-client" 24 + */ 25 + 26 + export { OAuthUserAgent, getSession }; 27 + 28 + export const DID_STORAGE_KEY = "diffuse/supplement/rocksky/atproto/did"; 29 + const CLIENT_KEY = "diffuse/supplement/rocksky"; 30 + 31 + const SCOPE = metadata.scope; 32 + 33 + // CONFIGURE 34 + // ========= 35 + 36 + const location = globalThis.location; 37 + 38 + const redirect_uri = location.origin + location.pathname + location.search; 39 + 40 + const isLocalDev = redirect_uri.startsWith("http://127.0.0.1") || 41 + redirect_uri.startsWith("http://localhost"); 42 + 43 + const client_id = isLocalDev 44 + ? `http://localhost/?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${ 45 + encodeURIComponent(SCOPE) 46 + }` 47 + : /** @type {any} */ (import.meta).env?.ROCKSKY_ATPROTO_CLIENT_ID ?? 48 + "https://elements.diffuse.sh/rocksky-oauth-client-metadata.json"; 49 + 50 + configureOAuth({ 51 + metadata: { client_id, redirect_uri }, 52 + storageName: "diffuse/supplement/rocksky/atcute/oauth", 53 + identityResolver: new LocalActorResolver({ 54 + handleResolver: new XrpcHandleResolver({ 55 + serviceUrl: "https://public.api.bsky.app", 56 + }), 57 + didDocumentResolver: new CompositeDidDocumentResolver({ 58 + methods: { 59 + plc: new PlcDidDocumentResolver(), 60 + web: new WebDidDocumentResolver(), 61 + }, 62 + }), 63 + }), 64 + }); 65 + 66 + // LOGIN 67 + // ===== 68 + 69 + /** 70 + * Initiate the Rocksky OAuth authorization flow for a given handle. 71 + * Navigates the browser away to the authorization server. 72 + * 73 + * @param {string} handle 74 + */ 75 + export async function login(handle) { 76 + sessionStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); 77 + sessionStorage.setItem("oauth/pending-client", CLIENT_KEY); 78 + 79 + const authUrl = await createAuthorizationUrl({ 80 + target: { type: "account", identifier: /** @type {any} */ (handle) }, 81 + scope: SCOPE, 82 + }); 83 + 84 + location.assign(authUrl.toString()); 85 + } 86 + 87 + // SESSION RESTORE / CALLBACK 88 + // ========================== 89 + 90 + /** 91 + * Attempt to restore an existing Rocksky session or finalize an OAuth callback. 92 + * Returns the session if successful, or null if no session is available. 93 + * 94 + * @returns {Promise<Session | null>} 95 + */ 96 + export async function restoreOrFinalize() { 97 + const params = new URLSearchParams(location.hash.slice(1)); 98 + 99 + if (params.has("code") && sessionStorage.getItem("oauth/pending-client") === CLIENT_KEY) { 100 + sessionStorage.removeItem("oauth/pending-client"); 101 + 102 + const result = await finalizeAuthorization(params); 103 + 104 + history.replaceState(null, "", location.pathname + location.search); 105 + localStorage.setItem(DID_STORAGE_KEY, result.session.info.sub); 106 + 107 + return result.session; 108 + } 109 + 110 + const did = localStorage.getItem(DID_STORAGE_KEY); 111 + 112 + if (did) { 113 + try { 114 + return await getSession(/** @type {`did:${string}:${string}`} */ (did)); 115 + } catch (err) { 116 + console.warn(err); 117 + clearStoredSession(); 118 + return null; 119 + } 120 + } 121 + 122 + return null; 123 + } 124 + 125 + // CLEAR SESSION 126 + // ============= 127 + 128 + export function clearStoredSession() { 129 + const did = localStorage.getItem(DID_STORAGE_KEY); 130 + 131 + if (did) { 132 + deleteStoredSession(/** @type {`did:${string}:${string}`} */ (did)); 133 + } 134 + 135 + localStorage.removeItem(DID_STORAGE_KEY); 136 + } 137 + 138 + // LOGOUT 139 + // ====== 140 + 141 + /** 142 + * @param {OAuthUserAgent} agent 143 + */ 144 + export async function logout(agent) { 145 + const did = localStorage.getItem(DID_STORAGE_KEY); 146 + 147 + try { 148 + await agent.signOut(); 149 + } catch { 150 + if (did) { 151 + deleteStoredSession(/** @type {`did:${string}:${string}`} */ (did)); 152 + } 153 + } 154 + 155 + localStorage.removeItem(DID_STORAGE_KEY); 156 + }
+1 -23
src/facets/connect/atproto/index.inline.js
··· 23 23 await customElements.whenDefined(outputOrchestrator.localName); 24 24 await customElements.whenDefined(ATPROTO_NAME); 25 25 26 - // The AT Protocol option is added dynamically by the output-bundle facet, which 27 - // runs in a separate script and appends the element to the output configurator 28 - // via a signal effect. That effect may not have fired yet when this facet loads, 29 - // so we observe the configurator for child-list mutations and resolve as soon as 30 - // the option appears. 31 - const outputConfigurator = outputOrchestrator.outputConfigurator; 32 - 33 - const atprotoOption = await new Promise((resolve) => { 34 - const check = async () => { 35 - const opt = (await outputOrchestrator.options()).find( 36 - (o) => o.label === "AT Protocol", 37 - ); 38 - if (opt) { 39 - observer.disconnect(); 40 - resolve(opt); 41 - } 42 - }; 43 - 44 - const observer = new MutationObserver(check); 45 - observer.observe(outputConfigurator, { childList: true }); 46 - check(); 47 - }); 48 - 26 + const atprotoOption = await outputOrchestrator.waitForOption("AT Protocol"); 49 27 const ATPROTO_OUTPUT_ID = atprotoOption.id; 50 28 51 29 const atprotoEl = /** @type {ATProtoOutputElement | undefined} */ (
+4 -4
src/facets/misc/scrobble/listenbrainz/index.html
··· 37 37 <input id="token-input" type="password" placeholder="Your user token" /> 38 38 <span class="caption">Find your token at listenbrainz.org/settings/</span> 39 39 </label> 40 - <div class="button-row"> 40 + <p class="button-row"> 41 41 <button id="sign-in-btn" class="button--brand"> 42 42 <i class="ph-bold ph-plugs"></i> 43 43 Connect 44 44 </button> 45 - </div> 45 + </p> 46 46 </div> 47 47 48 48 <div id="state-connected" hidden> 49 49 <p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p> 50 - <div class="button-row"> 50 + <p class="button-row"> 51 51 <button id="sign-out-btn" hidden> 52 52 <i class="ph-bold ph-plugs-connected"></i> 53 53 Disconnect 54 54 </button> 55 - </div> 55 + </p> 56 56 </div> 57 57 </div> 58 58 </main>
+1 -54
src/facets/misc/scrobble/rocksky/index.html
··· 13 13 } 14 14 </style> 15 15 16 - <main> 17 - <div class="facet__left"> 18 - <div> 19 - <a href="./dashboard/" class="diffuse-logo-container"> 20 - <svg viewBox="0 0 902 134" width="160"> 21 - <title>Diffuse</title> 22 - <use 23 - xlink:href="images/diffuse-current.svg#diffuse" 24 - href="images/diffuse-current.svg#diffuse" 25 - ></use> 26 - </svg> 27 - </a> 28 - </div> 29 - <h1>Rocksky</h1> 30 - <p>Scrobble tracks to Rocksky using your AT Protocol identity.</p> 31 - </div> 32 - 33 - <div class="facet__right"> 34 - <div id="state-connect"> 35 - <div id="state-no-atproto"> 36 - <p>Sign in with your AT Protocol identity first, then connect to Rocksky.</p> 37 - <form id="atproto-sign-in-form"> 38 - <label>Handle <input id="handle-input" placeholder="you.bsky.social" /></label> 39 - <p class="button-row"> 40 - <button id="atproto-sign-in-btn"> 41 - <i class="ph-bold ph-at"></i> 42 - Sign in with AT Protocol 43 - </button> 44 - </p> 45 - </form> 46 - </div> 47 - 48 - <div id="state-has-atproto" hidden> 49 - <p>Connect your Rocksky account using your AT Protocol identity.</p> 50 - <div class="button-row"> 51 - <button id="sign-in-btn" class="button--brand"> 52 - <i class="ph-bold ph-plugs"></i> 53 - Connect 54 - </button> 55 - </div> 56 - </div> 57 - </div> 58 - 59 - <div id="state-connected" hidden> 60 - <p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p> 61 - <div class="button-row"> 62 - <button id="sign-out-btn" hidden> 63 - <i class="ph-bold ph-plugs-connected"></i> 64 - Disconnect 65 - </button> 66 - </div> 67 - </div> 68 - </div> 69 - </main> 16 + <main></main> 70 17 71 18 <script type="module" src="facets/misc/scrobble/rocksky/index.inline.js"></script>
+71 -104
src/facets/misc/scrobble/rocksky/index.inline.js
··· 1 - import { login } from "~/components/output/raw/atproto/oauth.js"; 2 - import { finalizeAuthorization } from "@atcute/oauth-browser-client"; 1 + import { html, nothing, render as litRender } from "lit-html"; 3 2 4 3 import foundation from "~/common/foundation.js"; 5 4 import { effect, signal } from "~/common/signal.js"; ··· 11 10 // Set doc title 12 11 foundation.setup({ title: "Rocksky | Scrobble | Diffuse" }); 13 12 14 - const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; 15 - 16 - // Handle AT Protocol OAuth callback if returning from it. 17 - // The /oauth/callback page passes the #code fragment back to this page's URL. 18 - // We only finalize if the code is actually present — never attempt session 19 - // restoration, as its error path calls clearStoredSession() which would wipe 20 - // the main app's AT Protocol session from localStorage and IndexedDB. 21 - let freshAtprotoSession = null; 22 - const hashParams = new URLSearchParams(location.hash.slice(1)); 23 - if (hashParams.has("code")) { 24 - try { 25 - const result = await finalizeAuthorization(hashParams); 26 - history.replaceState(null, "", location.pathname + location.search); 27 - localStorage.setItem(ATPROTO_DID_KEY, result.session.info.sub); 28 - freshAtprotoSession = result.session; 29 - } catch (err) { 30 - console.warn("rocksky: failed to finalize AT Protocol auth", err); 31 - } 32 - } 33 - 34 13 const configurator = await foundation.configurator.scrobbles(); 35 14 36 15 /** @type {import("~/components/supplement/rocksky/element.js").CLASS | null} */ ··· 47 26 48 27 await customElements.whenDefined(rocksky.localName); 49 28 50 - // If AT Protocol was just authorized via OAuth, immediately connect to Rocksky 51 - if (freshAtprotoSession && !rocksky.isAuthenticated()) { 52 - rocksky.signIn().catch(() => {}); 53 - } 54 - 55 - //////////////////////////////////////////// 56 - // ELEMENTS 57 - //////////////////////////////////////////// 58 - 59 - const stateConnect = /** @type {HTMLElement} */ ( 60 - document.querySelector("#state-connect") 61 - ); 62 - 63 - const stateConnected = /** @type {HTMLElement} */ ( 64 - document.querySelector("#state-connected") 65 - ); 66 - 67 - const stateNoAtproto = /** @type {HTMLElement} */ ( 68 - document.querySelector("#state-no-atproto") 69 - ); 70 - 71 - const stateHasAtproto = /** @type {HTMLElement} */ ( 72 - document.querySelector("#state-has-atproto") 73 - ); 74 - 75 - const handleParagraph = /** @type {HTMLElement} */ ( 76 - document.querySelector("#handle-paragraph") 77 - ); 78 - 79 - const handleText = /** @type {HTMLElement} */ ( 80 - document.querySelector("#handle-text") 81 - ); 82 - 83 - const handleInput = /** @type {HTMLInputElement} */ ( 84 - document.querySelector("#handle-input") 85 - ); 86 - 87 - const atprotoSignInBtn = /** @type {HTMLElement} */ ( 88 - document.querySelector("#atproto-sign-in-btn") 89 - ); 90 - 91 - const signInBtn = /** @type {HTMLElement} */ ( 92 - document.querySelector("#sign-in-btn") 93 - ); 94 - 95 - const signOutBtn = /** @type {HTMLElement} */ ( 96 - document.querySelector("#sign-out-btn") 97 - ); 98 - 99 29 //////////////////////////////////////////// 100 30 // REACTIVE UI 101 31 //////////////////////////////////////////// 102 32 103 - const $hasAtprotoSession = signal(!!localStorage.getItem(ATPROTO_DID_KEY)); 33 + const $signingIn = signal(false); 34 + 35 + const main = document.querySelector("main"); 104 36 105 37 effect(() => { 106 38 const isAuthenticated = rocksky.isAuthenticated(); 107 39 const isAuthenticating = rocksky.isAuthenticating(); 108 40 const handle = rocksky.handle(); 109 - const hasAtproto = $hasAtprotoSession.value; 41 + const signingIn = $signingIn.value; 110 42 111 - stateConnect.hidden = isAuthenticated; 112 - stateConnected.hidden = !isAuthenticated; 43 + litRender( 44 + html` 45 + <div class="facet__left"> 46 + <div> 47 + <a href="./dashboard/" class="diffuse-logo-container"> 48 + <svg viewBox="0 0 902 134" width="160"> 49 + <title>Diffuse</title> 50 + <use 51 + xlink:href="images/diffuse-current.svg#diffuse" 52 + href="images/diffuse-current.svg#diffuse" 53 + ></use> 54 + </svg> 55 + </a> 56 + </div> 57 + <h1>Rocksky</h1> 58 + <p>Scrobble tracks to Rocksky using your AT Protocol identity.</p> 59 + </div> 113 60 114 - stateNoAtproto.hidden = hasAtproto; 115 - stateHasAtproto.hidden = !hasAtproto; 116 - 117 - handleParagraph.hidden = !handle; 118 - signOutBtn.hidden = !isAuthenticated; 119 - if (handle) handleText.textContent = handle; 120 - 121 - // @ts-ignore 122 - signInBtn.disabled = isAuthenticating; 123 - signInBtn.querySelector("i").className = isAuthenticating 124 - ? "ph-bold ph-spinner animate-spin" 125 - : "ph-bold ph-plugs"; 126 - 127 - // @ts-ignore 128 - atprotoSignInBtn.disabled = isAuthenticating; 129 - atprotoSignInBtn.querySelector("i").className = isAuthenticating 130 - ? "ph-bold ph-spinner animate-spin" 131 - : "ph-bold ph-at"; 61 + <div class="facet__right"> 62 + ${isAuthenticated 63 + ? html` 64 + <div> 65 + ${handle 66 + ? html`<p>Connected as <strong>${handle}</strong>.</p>` 67 + : nothing} 68 + <div class="button-row"> 69 + <button @click="${() => rocksky.signOut()}"> 70 + <i class="ph-bold ph-plugs-connected"></i> 71 + Disconnect 72 + </button> 73 + </div> 74 + </div> 75 + ` 76 + : html` 77 + <div> 78 + <p>Sign in with your AT Protocol identity to connect Rocksky.</p> 79 + <form @submit="${handleSignIn}"> 80 + <label 81 + >Handle 82 + <input placeholder="you.bsky.social" /> 83 + </label> 84 + <p class="button-row"> 85 + <button ?disabled="${isAuthenticating || signingIn}"> 86 + <i 87 + class="ph-bold ${isAuthenticating || signingIn 88 + ? "ph-spinner animate-spin" 89 + : "ph-at"}" 90 + ></i> 91 + Connect with AT Protocol 92 + </button> 93 + </p> 94 + </form> 95 + </div> 96 + `} 97 + </div> 98 + `, 99 + main, 100 + ); 132 101 }); 133 102 134 103 //////////////////////////////////////////// 135 104 // ACTIONS 136 105 //////////////////////////////////////////// 137 106 138 - const atprotoSignInForm = /** @type {HTMLFormElement} */ ( 139 - document.querySelector("#atproto-sign-in-form") 140 - ); 141 - 142 - atprotoSignInForm.onsubmit = async (e) => { 107 + async function handleSignIn(e) { 143 108 e.preventDefault(); 144 - const handle = handleInput.value?.trim(); 109 + const handle = e.target.querySelector("input")?.value?.trim(); 145 110 if (!handle) return; 146 - await login(handle); 147 - }; 148 - 149 - signInBtn.onclick = () => rocksky.signIn().catch(() => {}); 150 - signOutBtn.onclick = () => rocksky.signOut(); 111 + $signingIn.value = true; 112 + try { 113 + await rocksky.signIn(handle); 114 + } finally { 115 + $signingIn.value = false; 116 + } 117 + } 151 118 152 119 //////////////////////////////////////////// 153 120 // 🚀
+1 -1
src/oauth-client-metadata.json src/components/output/raw/atproto/oauth-client-metadata.json
··· 1 1 { 2 - "client_id": "https://elements.diffuse.sh/oauth-client-metadata.json", 2 + "client_id": "https://elements.diffuse.sh/latest/components/output/raw/atproto/oauth-client-metadata.json", 3 3 "client_name": "Diffuse", 4 4 "client_uri": "https://elements.diffuse.sh", 5 5 "redirect_uris": ["https://elements.diffuse.sh/oauth/callback"],