// ==UserScript== // @name Mastodon - Open bsky.app links via Bridgy Fed // @namespace https://github.com/tesaguri // @grant GM.getValue // @grant GM_addValueChangeListener // @version 1.0.0 // @updateURL https://github.com/tesaguri/userscripts/raw/main/mastodon-detect-bridge/index.user.js // @author Daiki "tesaguri" Mizukami // @license GPL-3.0-only; https://www.gnu.org/licenses/gpl-3.0.txt // @description Open bsky.app links via Bridgy Fed (or optionally via corresponding PDS) // ==/UserScript== /** * Optional configurations to be stored in the script storage. * @typedef {object} Config * @property {AtprotoConfig} [atproto] */ /** * Configurations specific to AT Protocol. * @typedef {object} AtprotoConfig * @property {AtprotoFallbackBehavior} [fallbackBehavior] */ /** * Fallback behavior when an atproto resource isn't bridged via Bridgy Fed. * - `openPds` - Open the resource via its corresponding PDS endpoint. * - `default` - Open the original AppView URL. * @typedef {'openPds' | 'default'} AtprotoFallbackBehavior */ /** * @template T * @typedef {(T | null)[] | T?} LdOptional */ /** * @template T * @typedef {[...(T | null)[], T, ...(T | null)[]] | T} LdRequired */ /** * @typedef {string | { '@id': string } | { id: string }} LdId * @typedef {{ '@type': string[] | string } | { type: string[] | string }} HasLdType // `@type` cannot have `null`. * @typedef {`did:${string}`} DidString * @typedef {HasLdType & { serviceEndpoint: LdRequired<LdId> }} Service * @typedef {LdId & { service?: LdOptional<Service> }} DidDocument */ (() => { // INIT addEventListener('click', clickEventListener); /** * @param {MouseEvent} e * @returns {void} */ function clickEventListener(e) { if (!(e.target instanceof HTMLElement)) { return; } let target = e.target; while (true) { if ( target instanceof HTMLAnchorElement && target.classList.contains('unhandled-link') && target.href.startsWith('https://bsky.app/profile/') && !target.classList.contains('status-url-link') ) { break; } if (!target.parentElement) { return; } target = target.parentElement; } const atUri = AtUri.fromBskyUrl(target.href); if (!atUri) { return; } // XXX: We've checked that `e.target` is an `HTMLElement`, but still need to convince // `tsc`. /** @type {typeof e & { readonly target: HTMLElement }} */ const event = /** @type {any} */ (e); if (!('collection' in atUri) || atUri.collection === 'app.bsky.feed.post') { // Speculatively preventing the default action because `preventDefault` would have no // effect in the async callback called after checking the bridge status. // Instead, we'll retry the click event in the fallback procedure where appropriate. e.preventDefault(); resolveBridgeUri(atUri) .then(async bridgeUri => { if (bridgeUri !== undefined) { submitSearch(bridgeUri); return; } await atprotoFallback(event, atUri); }).catch(async e => { console.error(e); await atprotoFallback(event, atUri); }); } else { atprotoFallback(event, atUri); } } /** @type {Config} */ let config = Object.create(null); const initFallbackBehavior = GM.getValue('atproto').then(setAtprotoConfig); GM_addValueChangeListener('atproto', (_name, _oldValue, value) => { setAtprotoConfig(value); }); // UTILITIES - Generic /** * @param {unknown} value * @returns {void} */ function setAtprotoConfig(value) { if (typeof value === 'object' && value) { /** @type {AtprotoConfig} */ config.atproto = config.atproto || Object.create(null); if ('fallbackBehavior' in value) { if (typeof value.fallbackBehavior !== 'string') { console.warn(`${GM.info.script.name}: \`config.fallbackBehavior.atproto\` must be a string`); delete config.atproto.fallbackBehavior; } else if (value.fallbackBehavior !== 'openPds' && value.fallbackBehavior !== 'default') { console.warn(`${GM.info.script.name}: unknown value for \`config.atproto.fallbackBehavior\`: ${value.fallbackBehavior}`); delete config.atproto.fallbackBehavior; } else { config.atproto.fallbackBehavior = value.fallbackBehavior; } } } else { console.warn(`${GM.info.script.name}: \`config.fallbackBehavior\` must be an object`); delete config.atproto; } } /** * @param {string | URL} [url] * @param {string} [target] * @param {string} [windowFeatures] * @returns {ReturnType<typeof open>} */ function safeOpen(url, target, windowFeatures) { const defaultWindowFeatures = 'noreferrer'; return open(url, target, windowFeatures ? `${defaultWindowFeatures},${windowFeatures}` : defaultWindowFeatures); } /** * @param {Response} response * @returns {asserts response is { ok: true }} */ function assertResponseIsOk(response) { if (!response.ok) { throw Error(`HTTP ${response.status}: ${response.url}`); } } // UTILITIES - Mastodon /** * @param {string} query * @param {number} [retryCount] * @returns {void} */ function submitSearch(query, retryCount) { /** @type {HTMLInputElement | null} */ let input = document.querySelector('input.search__input'); if (!input) { if ((retryCount ?? 0) > 10) { console.error('Cannot find the search input'); return; } // Click the "search tab" button to display the search input. if (!location.pathname.startsWith('/web/search')) { /** @type {HTMLAnchorElement | null} */ const searchTab = document.querySelector('a[data-preview-title-id="tabs_bar.search"]'); if (!searchTab) { console.error('Cannot find the search tab button'); return; } searchTab.click(); input = document.querySelector('input.search__input'); } if (!input) { // HACK: Retry until the input appears. requestAnimationFrame(() => submitSearch(query, (retryCount ?? 0) + 1)); return; } } input.focus(); // <https://hustle.bizongo.in/simulate-react-on-change-on-controlled-components-baa336920e04> const valueProperty = /** @type {NonNullable<ReturnType<typeof Object.getOwnPropertyDescriptor>>} */ (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')); const setValue = /** @type {NonNullable<typeof valueProperty.set>} */ (valueProperty.set); setValue.call(input, query); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); } // UTILITIES - DID/JSON-LD /** * @param {string} s * @returns {s is DidString} */ function isDidString(s) { return s.startsWith('did:'); } const acceptDidHeaders = new Headers([['accept', 'application/did+ld+json']]); /** * @typedef {object} ResolveDidInit * @property {AbortSignal} [signal] */ /** * @param {DidString} did * @param {ResolveDidInit} [options] * @returns {Promise<DidDocument>} */ async function resolveDid(did, options) { let url; if (did.startsWith('did:plc:')) { url = `https://plc.directory/${did}`; } else if (did.startsWith('did:web:')) { url = `https://${did.slice(8)}/.well-known/did.json`; } else { throw new Error(`Unrecognized DID: ${did}`); } const res = await fetch(url, { ...options, headers: acceptDidHeaders, referrer: '', }); assertResponseIsOk(res); const ret = await res.json(); assertIsDidDocument(ret); return ret; } /** * @param {any} x * @returns {asserts x is LdId} */ function assertIsLdId(x) { if (typeof x !== 'string' && ( ('@id' in x && typeof x['@id'] !== 'string') || ('id' in x && typeof x.id !== 'string') )) { throw TypeError('Argument is not an `@id`'); } } /** * @param {any} x * @returns {asserts x is HasLdType} */ function assertHasLdType(x) { if ( ('@type' in x && !isLdTypeValue(x['@type'])) || ('type' in x && !isLdTypeValue(x.type)) ) { throw TypeError('@type must be a string or an array of strings'); } } /** * @param {any} t * @returns {t is string[] | string} */ function isLdTypeValue(t) { if (Array.isArray(t)) { return t.every(x => typeof x === 'string'); } else { return typeof t === 'string'; } } /** * @param {any} x * @returns {asserts x is DidDocument} */ function assertIsDidDocument(x) { assertIsLdId(x); // @ts-expect-error // implicitly asserting that `x` is an object. 'service' in x && (x.service === null || asArray(x.service).forEach(assertIsService)); } /** * @param {any} x * @returns {asserts x is Service} */ function assertIsService(x) { assertHasLdType(x); if ('serviceEndpoint' in x) { for (const serviceEndpoint of asArray(x.serviceEndpoint)) { assertIsLdId(serviceEndpoint); } } } /** * @param {LdId} node * @returns {string} */ function ldIdOf(node) { if (typeof node === 'string') { return node; } else if ('@id' in node) { return node['@id']; } else { return node.id; } } /** * @param {HasLdType} node * @returns {string[]} */ function ldTypeOf(node) { if ('@type' in node) { return asArray(node['@type']); } else { return asArray(node.type); } } /** * @template T * @overload * @param {LdRequired<T>} value * @returns {T[]} */ /** * @template T * @param {LdOptional<T> | undefined} value * @returns {(T | null)[]} */ /** * @template T * @param {LdOptional<T> | undefined} value * @returns {(T | null)[]} */ function asArray(value) { if (Array.isArray(value)) { return value; } else if (value === null || value === undefined) { return []; } else { return [value]; } } /** * @template T * @overload * @param {LdRequired<T>} set * @returns {T} */ /** * @template T * @overload * @param {LdOptional<T> | undefined} set * @returns {(T | undefined)?} */ /** * @template T * @param {LdOptional<T> | undefined} set * @returns {(T | undefined)?} */ function firstOfSet(set) { if (Array.isArray(set)) { return set.find(x => x !== null); } else { return set; } } // UTILITIES - AT Protocol class AtUri { /** * @typedef {AtUriAuthority | AtUriCollection | AtUriRecord} AtUriComponents * @typedef {{ authority: string }} AtUriAuthority * @typedef {AtUriAuthority & { collection: string }} AtUriCollection * @typedef {AtUriCollection & { rkey: string }} AtUriRecord * @typedef {AtUri & { components: AtUriComponents & { authority: DidString } }} AtUriWithDid */ /** @type {AtUriComponents} */ components; /** * @overload * @param {string} authority */ /** * @overload * @param {string} authority * @param {string} collection */ /** * @overload * @param {string} authority * @param {string} collection * @param {string} rkey */ /** * @overload * @param {AtUriComponents} components */ /** * @param {string | AtUriComponents} authorityOrComponents * @param {string} [collection] * @param {string} [rkey] */ constructor(authorityOrComponents, collection, rkey) { if (typeof authorityOrComponents === 'object') { this.components = authorityOrComponents; } else { /** @type {Partial<AtUriRecord> & AtUriAuthority} */ const components = { authority: authorityOrComponents }; if (collection !== undefined) { components.collection = collection; if (rkey !== undefined) { components.rkey = rkey; } } this.components = components; } } /** * @param {string} url * @returns {AtUri | void} */ static fromBskyUrl(url) { const segments = url.split('/'); const authority = segments[4]; if (authority === undefined) { return; } const bskyCollection = segments[5]; if (bskyCollection === undefined) { return new this(authority); } const rkey = segments[6]; let collection; if (rkey !== undefined) { switch (bskyCollection) { case 'post': collection = 'app.bsky.feed.post'; break; case 'feed': collection = 'app.bsky.feed.generator'; break; } } if (collection !== undefined) { if (rkey !== undefined) { return new this(authority, collection, rkey); } return new this(authority, collection); } } /** @returns {typeof this.components.authority} */ get authority() { return this.components.authority; } /** @returns {this is AtUriWithDid} */ authorityIsDidString() { return isDidString(this.components.authority); } /** * @typedef {object} WithDidAuthorityInit * @property {AbortSignal} [signal] */ /** * @param {WithDidAuthorityInit} [options] * @returns {Promise<AtUriWithDid>} */ async withDidAuthority(options) { if (this.authorityIsDidString()) { return this; } else { const did = await resolveAtprotoHandle(this.authority, options); if (!did) { throw Error(`Unable to resolve handle ${this.pickAuthority()}`); } /** @type {AtUriComponents & { authority: DidString }} */ const components = { ...this.components, authority: did }; return /** @type {AtUriWithDid} */ (new AtUri(components)); } } pickAuthority() { if ('collection' in this) { return new AtUri(this.authority); } else { return this; } } toString() { let ret = `at://${this.authority}`; if ('collection' in this.components) { ret += `/${this.components.collection}`; if ('rkey' in this.components) { ret += `/${this.components.rkey}`; } } return ret; } } /** * @param {Event & { readonly target: HTMLElement }} event * @param {AtUri} uri * @returns {Promise<void>} */ async function atprotoFallback(event, uri) { await initFallbackBehavior; switch (config.atproto?.fallbackBehavior) { case 'openPds': try { event.preventDefault(); const uriWithDid = await uri.withDidAuthority(); const url = await pdsXrpcUrlForAtUri(uriWithDid); if (url !== undefined) { safeOpen(url); break; } console.warn(`Missing PDS for ${uri.authority}${uri.authorityIsDidString() ? '' : ` (${uriWithDid.authority})`}`); } catch (e) { console.error(e); } // Fall-through default: if (event.defaultPrevented) { try { removeEventListener('click', clickEventListener); // Using `click()` because `event.target.dispatchEvent(event)` won't open the link. event.target.click(); } finally { addEventListener('click', clickEventListener); } } } } const acceptDnsJsonHeaders = new Headers([['accept', 'application/dns-json']]); /** @type {Record<string, DidString>} */ const resolvedHandles = Object.create(null); /** * @typedef {object} ResolveAtprotoHandleInit * @property {AbortSignal} [signal] */ /** * @param {string} handle * @param {ResolveAtprotoHandleInit} [options] * @returns {Promise<DidString | void>} */ async function resolveAtprotoHandle(handle, options) { handle = handle.toLowerCase(); if (handle in resolvedHandles) { return resolvedHandles[handle]; } const ret = await resolveAtprotoHandleInner(handle, options); if (ret) { resolvedHandles[handle] = ret; return ret; } } /** * @param {string} handle * @param {ResolveAtprotoHandleInit} [options] * @returns {Promise<DidString | void>} */ async function resolveAtprotoHandleInner(handle, options) { try { const res = await fetch(`https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { ...options, headers: acceptDnsJsonHeaders, referrer: '', }); if (res.ok) { // We are intentionally loose about the `any` type here because a `TypeError` would // be caught by the `try` block just fine. const answers = /** @type {any} */ (await res.json()).Answer; if (Array.isArray(answers)) { const expectedName = `_atproto.${handle}`; for (const answer of answers) { /** @type {any} */ const ans = answer; if ( ans.name === expectedName && ans.type === 16 && ans.data?.startsWith('"did=did:') && ans.data.endsWith('"') ) { return ans.data.slice(5, -1); } } } } } catch { // Fall back on well-known } try { const res = await fetch(`https://${handle}/.well-known/atproto-did`, { referrer: '', }); if (res.ok) { const body = (await res.text()).trim(); if (isDidString(body)) { return body; } } } catch { // noop } } /** * @param {DidDocument} doc * @returns {string | void} */ function pdsFromDidDoc(doc) { const service = asArray(doc.service) .find(service => !!service && ldTypeOf(service).includes('AtprotoPersonalDataServer')); if (service) { return ldIdOf(firstOfSet(service.serviceEndpoint)); } } /** * @param {AtUriWithDid} uri * @returns {Promise<string | void>} */ async function pdsXrpcUrlForAtUri(uri) { const pds = pdsFromDidDoc(await resolveDid(uri.components.authority)); if (pds === undefined) { return; } if ('collection' in uri.components) { if ('rkey' in uri.components) { return getComAtprotoRepoGetRecord(pds, { repo: uri.components.authority, collection: uri.components.collection, rkey: uri.components.rkey, }); } else { return getComAtprotoRepoListRecords(pds, { repo: uri.components.authority, collection: uri.components.collection, }); } } else { return getComAtprotoRepoDescribeRepo(pds, { repo: uri.components.authority, }); } } // UTILITIES - XRPC /** * @typedef {object} GetComAtprotoRepoDescribeRepoInit * @property {DidString} repo */ /** * @param {string} pds * @param {GetComAtprotoRepoDescribeRepoInit} params */ function getComAtprotoRepoDescribeRepo(pds, { repo }) { return `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${repo}`; } /** * @typedef {object} GetComAtprotoRepoGetRecordInit * @property {DidString} repo * @property {string} collection * @property {string} rkey */ /** * @param {string} pds * @param {GetComAtprotoRepoGetRecordInit} params */ function getComAtprotoRepoGetRecord(pds, { repo, collection, rkey }) { return `${pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`; } /** * @template {object} [T=JSONObject] * @typedef {object} GetComAtprotoRepoGetRecordResponse<T> * @property {T} value */ /** * @param {JSONValue} x * @returns {asserts x is GetComAtprotoRepoGetRecordResponse} */ function assertIsGetComAtprotoRepoGetRecordResponse(x) { // @ts-expect-error if (typeof x.value !== 'object') { throw TypeError('`value` must be an object'); } } /** * @typedef {object} GetComAtprotoRepoListRecordsInit * @property {DidString} repo * @property {string} collection */ /** * @param {string} pds * @param {GetComAtprotoRepoListRecordsInit} params */ function getComAtprotoRepoListRecords(pds, { repo, collection }) { return `${pds}/xrpc/com.atproto.repo.listRecords?repo=${repo}&collection=${collection}`; } // UTILITIES - Bridgy Fed /** * Represents an AT Protocol resource bridged to the Fediverse or bridged from the * Fediverse/Web. */ class BridgedAtprotoResource { /** * Returns the corresponding Activity Streams resource URI. * @abstract * @returns {string} */ as2Url() { throw Error('Not implemented'); } } class BridgeFromBsky extends BridgedAtprotoResource { /** * @param {AtUri} uri */ constructor(uri) { super(); this.uri = uri; } /** * @param {AtUri} uri * @returns {string} */ static bridgeUriFromAtUri(uri) { if ('collection' in uri.components) { return `https://bsky.brid.gy/convert/ap/${uri}`; } else { return `https://bsky.brid.gy/ap/${uri.authority}`; } } /** @override */ as2Url() { return BridgeFromBsky.bridgeUriFromAtUri(this.uri); } } class BridgeFromAnotherProtocol extends BridgedAtprotoResource { /** * @param {string} originalUrl */ constructor(originalUrl) { super(); this.originalUrl = originalUrl; } } class BridgeFromActivityPub extends BridgeFromAnotherProtocol { /** @override */ as2Url() { return this.originalUrl; } } class BridgeFromWeb extends BridgeFromAnotherProtocol { /** * @param {string} url * @returns {string} */ static bridgeUriFromWebUrl(url) { return `https://web.brid.gy/r/${url}`; } /** @override */ as2Url() { return BridgeFromWeb.bridgeUriFromWebUrl(this.originalUrl); } } const bridgeNotFound = Symbol('Bridge not found'); /** @type {Record<string, BridgedAtprotoResource>} */ const bridgedResources = Object.create(null); /** * Checks if an AT Protocol resource is bridged via Bridgy Fed (both *from Bluesky* to the * Fediverse or from the Fediverse/Web *to Bluesky*) and returns the corresponding Activity * Streams resource URI if it's bridged. * @param {AtUri} uri * @returns {Promise<string | void>} */ async function resolveBridgeUri(uri) { const cached = bridgedResources[uri.toString()]; if (cached) { return cached.as2Url(); } const authorityQueries = [uri.pickAuthority().toString()]; const cachedDid = resolvedHandles[uri.authority]; if (cachedDid) { authorityQueries.push(new AtUri(cachedDid).toString()); } for (const authority of authorityQueries) { const cachedAuthority = bridgedResources[authority]; if (cachedAuthority) { if (cachedAuthority instanceof BridgeFromBsky) { return BridgeFromBsky.bridgeUriFromAtUri(uri); } else if (!('collection' in uri.components)) { return cachedAuthority.as2Url(); } } } // The resource may either be bridged *from Bluesky* to the Fediverse or from the // Fediverse/Web *to Bluesky* (or not bridged at all), both of which case require different // methods to determine the corresponding Activity Streams resource URI. So we'll run both // the resolvers concurrently. const controller = new AbortController(); const promises = [ resolveBskyBridgeUri(uri, controller.signal), resolveBridgeUriFromAtprotoRecord(uri, controller.signal), ].map(p => p.then(ret => { if (ret === undefined) { // Throw a value to prevent short-circuiting. throw bridgeNotFound; } return ret; })); try { return await Promise.any(promises); } catch (e) { if (e instanceof AggregateError) { const errors = e.errors.filter(e => e !== bridgeNotFound); if (errors.length === 0) { return; } else if (errors.length === 1) { e = errors[0]; } else { e.errors = errors; } } throw e; } finally { controller.abort(); } } const acceptAs2Headers = new Headers([ ['accept', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'], ]); /** * Checks if an AT Protocol resource is bridged to the Fediverse via Bridgy Fed and returns the * bridged Activity Stream resource URI if it's bridged. * @param {AtUri} uri * @param {AbortSignal} signal * @returns {Promise<string | void>} */ async function resolveBskyBridgeUri(uri, signal) { const bskyBridgeUri = BridgeFromBsky.bridgeUriFromAtUri(uri); const res = await fetch(bskyBridgeUri, { method: 'HEAD', headers: acceptAs2Headers, referrer: '', signal, }); if (res.ok) { const authority = uri.pickAuthority(); bridgedResources[authority.toString()] = new BridgeFromBsky(authority); return bskyBridgeUri; } } /** * Checks if an AT Protocol resource is bridged from the Fediverse or the Web via Bridgy Fed and * returns the original/bridged Activity Streams resource URI if it's bridged. * @param {AtUri} uri * @param {AbortSignal} signal * @returns {Promise<string | void>} */ async function resolveBridgeUriFromAtprotoRecord(uri, signal) { if ('collection' in uri.components && !('rkey' in uri.components)) { // Not sure how to map a colleciton-only URI to a bridge URL. return; } const uriWithDid = await uri.withDidAuthority({ signal }); const didDoc = await resolveDid(uriWithDid.components.authority, { signal }); const pds = pdsFromDidDoc(didDoc); if (pds === undefined) { return; } const profilePromise = resolveBskyProfile(pds, uriWithDid, signal); if ( !('collection' in uri.components) || ( uri.components.collection === 'app.bsky.actor.profile' && uri.components.rkey === 'self' ) ) { return (await profilePromise)?.as2Url(); } // XXX: Checking again to make `tsc` happy. if (!('rkey' in uri.components)) { return; } // Storing properties as local variables as the result of control-flow analysis doesn't seem // to propagate to inside closures, which is fair. const { collection, rkey } = uri.components; // `profilePromise` may have resolved immediately with `void` (in the quite common case of // non-bridged cached profiles), in which case we don't want to even initiate the HTTP // request for `recordPromise`, so we delay the request until the next event cycle, and // schedule the abort signal to abort before then if `profilePromise` has fulfilled with // `void`. const controller = new AbortController(); if (signal.aborted) { controller.abort(); } else { signal.addEventListener('abort', () => controller.abort()); } profilePromise.then(profile => { if (!profile) { controller.abort(); } }).catch(() => controller.abort()); const originalUrlPromise = new Promise(resolve => setTimeout(resolve)).then(async () => { const res = await fetch(getComAtprotoRepoGetRecord(pds, { repo: uriWithDid.components.authority, collection, rkey, }), { referrer: '', signal: controller.signal, }); assertResponseIsOk(res); const json = await res.json(); assertIsGetComAtprotoRepoGetRecordResponse(json); const originalUrl = json.value.bridgyOriginalUrl; if (typeof originalUrl === 'string') { return originalUrl; } return; }); /** @type {Promise<Awaited<profilePromise | originalUrlPromise>>[]} */ const promises = [profilePromise, originalUrlPromise]; const mapped = promises.map(p => p.then(x => { if (x === undefined) { // Throw a value to trigger short-circuiting. throw bridgeNotFound; } return x; })); /** @type {NonNullable<Awaited<typeof profilePromise>>} */ let profile; /** @type {NonNullable<Awaited<typeof originalUrlPromise>>} */ let originalUrl; try { [profile, originalUrl] = /** @type {[typeof profile, typeof originalUrl]} */ (await Promise.all(mapped)); } catch (e) { if (e === bridgeNotFound) { return; } throw e; } /** @type {BridgeFromAnotherProtocol} */ // @ts-expect-error const meta = new profile.constructor(originalUrl); bridgedResources[uri.toString()] = meta; if (uriWithDid !== uri) { bridgedResources[uri.toString()] = meta; } return meta.as2Url(); } /** * @param {string} pds * @param {AtUriWithDid} uri * @param {AbortSignal} signal * @returns {Promise<BridgeFromAnotherProtocol | void>} */ async function resolveBskyProfile(pds, uri, signal) { const authorityUri = uri.pickAuthority().toString(); const cached = bridgedResources[authorityUri]; if (cached) { if (cached instanceof BridgeFromAnotherProtocol) { return cached; } else { return; } } const res = await fetch(getComAtprotoRepoGetRecord(pds, { repo: uri.components.authority, collection: 'app.bsky.actor.profile', rkey: 'self', }), { referrer: '', signal, }); assertResponseIsOk(res); const json = await res.json(); assertIsGetComAtprotoRepoGetRecordResponse(json); const originalUrl = json.value.bridgyOriginalUrl; if (typeof originalUrl !== 'string') { return; } let ret; switch (originProtocolFromBskyProfileRecordValue(json.value)) { case 'activitypub': ret = new BridgeFromActivityPub(originalUrl); break; case 'web': ret = new BridgeFromWeb(originalUrl); break; } if (!ret) { return; } bridgedResources[authorityUri] = ret; return ret; } /** * @param {JSONObject} value * @returns {'activitypub' | 'web' | void} */ function originProtocolFromBskyProfileRecordValue(value) { if (typeof value.labels !== 'object' || Array.isArray(value.labels) || !Array.isArray(value.labels?.values)) { return; } for (const label of value.labels.values) { if (typeof label !== 'object' || Array.isArray(label)) { continue; } switch (label?.val) { case 'bridged-from-bridgy-fed-activitypub': return 'activitypub'; case 'bridged-from-bridgy-fed-web': return 'web'; } } } })();