Something went wrong. Try again.
Atproto AMA app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210/** * Headless Askimut Widget API client. Fetches Question and Answer data from * the public Widget API without rendering any UI — the same data the * `askimut-list` and `askimut-ask` elements consume. Public reads are open * CORS from any origin; no embedder registration, no auth. * * Ships three ways: `@askimut/widget/api` (registers nothing, no DOM * required), named exports of the main `@askimut/widget` bundle, and the * CDN module at `/widget/api.js` on the Askimut origin. */
import { moduleOrigin } from "./context.js";
/** * A Question's Asker, as the Widget API exposes it. `null` when unknown; * Question content is redacted when `askerUnavailable` is true * (Unavailable Asker — blocked, deleted, or suspended on its Source). * @typedef {Object} AskimutAuthor * @property {string} did * @property {string} handle * @property {string | null} displayName * @property {string | null} avatarUrl */
/** * The Recipient's Answer Content — the actual reply text, reusable across * Questions. * @typedef {Object} AskimutAnswerContent * @property {string} id * @property {string} content * @property {string} authorDid * @property {string | null} atUri * @property {string | null} sourceType * @property {string | null} originUrl * @property {string | null} originMetadata * @property {boolean} reindexed * @property {string | null} lastFetchedAt * @property {string} createdAt */
/** * The Answer linking a Question to its Answer Content. * @typedef {Object} AskimutAnswer * @property {string} id * @property {string | null} atUri * @property {boolean} reindexed * @property {boolean} derived - True for a Derived Answer (observed from the Atmosphere). * @property {string} createdAt * @property {string} answerContentUri * @property {AskimutAnswerContent | null} answerContent */
/** * One Question as serialized by the Widget API (dates are ISO strings). * Raw record fields (`sourceType`, `atUri`, `cid`, `sourceUri`, `sourceData`, * `reindexed`, `deleted`, `authorDid`, `targetDid`) are included verbatim. * @typedef {Object} AskimutQuestion * @property {string} id * @property {string} content * @property {string} createdAt * @property {AskimutAuthor | null} author * @property {boolean} [askerUnavailable] * @property {AskimutAnswer | null} [answer] */
/** * A stack of Questions sharing one Answer Content, as rendered on the * profile and in `<askimut-list>`: the newest Question first, up to two * similar Questions behind it, and the overflow count. * @typedef {Object} AskimutQuestionGroup * @property {AskimutQuestion} primary * @property {AskimutQuestion[]} similar * @property {number} hiddenCount */
/** * @typedef {Object} AskimutQuestions * @property {string} handle - The Recipient's handle as Askimut resolves it * (may be empty) — use it to build `/{handle}/{questionId}` permalinks. * @property {AskimutQuestionGroup[]} groups - Answered QuestionGroups, newest first. */
/** * Error thrown for any failed Widget API exchange: non-2xx response * (`status` is the HTTP status, `message` the server's error when present) * or a network failure (`status` 0). */export class AskimutApiError extends Error { /** HTTP status of the failed response; 0 when the request never completed. */ status; /** Requested URL. */ url;
/** * @param {number} status * @param {string} message * @param {{ url?: string, cause?: unknown }} [options] */ constructor(status, message, options = {}) { super(message, { cause: options.cause }); this.name = "AskimutApiError"; this.status = status; this.url = options.url ?? ""; }}
/** * @param {string | undefined} value * @param {string} name */function requireValue(value, name) { if (typeof value !== "string" || !value.trim()) { throw new TypeError(`Askimut widget client: ${name} is required`); } return value;}
/** * Askimut origin for API calls: the explicit option, else the origin this * module was loaded from (the CDN bundle's origin). Trailing slashes are * stripped so path joins stay well-formed. * @param {{ origin?: string } | undefined} options */function resolveOrigin(options) { const raw = options?.origin || moduleOrigin(); return raw.replace(/\/+$/, "");}
/** * @param {string} url * @returns {Promise<Response>} */async function request(url) { let response; try { response = await fetch(url); } catch (cause) { throw new AskimutApiError(0, "Network error reaching the Askimut API", { url, cause }); } if (!response.ok) { const body = await response.json().catch(() => null); const message = typeof body?.error === "string" && body.error ? body.error : `Askimut API responded with HTTP ${response.status}`; throw new AskimutApiError(response.status, message, { url }); } return response;}
/** * @param {Response} response */async function parseJson(response) { try { return await response.json(); } catch (cause) { throw new AskimutApiError(response.status, "Askimut API returned invalid JSON", { url: response.url, cause, }); }}
/** * Fetches a Recipient's answered Q&A — the same grouped data * `<askimut-list>` renders — without any UI. * * @param {string} recipient - Askimut handle or DID. * @param {{ origin?: string }} [options] - `origin`: Askimut origin for the * API call. Defaults to the origin this module was loaded from; set it * explicitly when importing the package from a bundler (the default would * point at your own site). * @returns {Promise<AskimutQuestions>} * @throws {AskimutApiError} When the Recipient is unknown (404) or the request fails. * @throws {TypeError} When `recipient` is missing. */export async function fetchQuestions(recipient, options = {}) { requireValue(recipient, "recipient"); const base = resolveOrigin(options); const response = await request(`${base}/api/widget/${encodeURIComponent(recipient)}/questions`); return { handle: response.headers.get("X-Askimut-Handle") ?? "", groups: await parseJson(response), };}
/** * Fetches one Question for a Recipient by id — the same public record the * `/{handle}/{questionId}` permalink page shows: answered or pending * (`answer: null`), never dismissed or deleted, Unavailable Asker redaction * applied — without any UI. * * @param {string} recipient - Askimut handle or DID the Question is addressed to. * @param {string} questionId - Question id (the permalink's last path segment). * @param {{ origin?: string }} [options] - See {@link fetchQuestions}. * @returns {Promise<AskimutQuestion>} * @throws {AskimutApiError} When the Recipient or Question is unknown (404) or the request fails. * @throws {TypeError} When `recipient` or `questionId` is missing. */export async function fetchQuestion(recipient, questionId, options = {}) { requireValue(recipient, "recipient"); requireValue(questionId, "questionId"); const base = resolveOrigin(options); const response = await request( `${base}/api/widget/${encodeURIComponent(recipient)}/questions/${encodeURIComponent(questionId)}`, ); return parseJson(response);}