···11+import React from "react";
22+import { AtProtoRecord } from "../core/AtProtoRecord";
33+import { TangledRepoRenderer } from "../renderers/TangledRepoRenderer";
44+import type { TangledRepoRecord } from "../types/tangled";
55+import { useAtProto } from "../providers/AtProtoProvider";
66+77+/**
88+ * Props for rendering Tangled Repo records.
99+ */
1010+export interface TangledRepoProps {
1111+ /** DID of the repository that stores the repo record. */
1212+ did: string;
1313+ /** Record key within the `sh.tangled.repo` collection. */
1414+ rkey: string;
1515+ /** Prefetched Tangled Repo record. When provided, skips fetching from the network. */
1616+ record?: TangledRepoRecord;
1717+ /** Optional renderer override for custom presentation. */
1818+ renderer?: React.ComponentType<TangledRepoRendererInjectedProps>;
1919+ /** Fallback node displayed before loading begins. */
2020+ fallback?: React.ReactNode;
2121+ /** Indicator node shown while data is loading. */
2222+ loadingIndicator?: React.ReactNode;
2323+ /** Preferred color scheme for theming. */
2424+ colorScheme?: "light" | "dark" | "system";
2525+ /** Whether to show star count from backlinks. Defaults to true. */
2626+ showStarCount?: boolean;
2727+ /** Branch to query for language information. Defaults to trying "main", then "master". */
2828+ branch?: string;
2929+ /** Prefetched language names (e.g., ['TypeScript', 'React']). When provided, skips fetching languages from the knot server. */
3030+ languages?: string[];
3131+}
3232+3333+/**
3434+ * Values injected into custom Tangled Repo renderer implementations.
3535+ */
3636+export type TangledRepoRendererInjectedProps = {
3737+ /** Loaded Tangled Repo record value. */
3838+ record: TangledRepoRecord;
3939+ /** Indicates whether the record is currently loading. */
4040+ loading: boolean;
4141+ /** Fetch error, if any. */
4242+ error?: Error;
4343+ /** Preferred color scheme for downstream components. */
4444+ colorScheme?: "light" | "dark" | "system";
4545+ /** DID associated with the record. */
4646+ did: string;
4747+ /** Record key for the repo. */
4848+ rkey: string;
4949+ /** Canonical external URL for linking to the repo. */
5050+ canonicalUrl: string;
5151+ /** Whether to show star count from backlinks. */
5252+ showStarCount?: boolean;
5353+ /** Branch to query for language information. */
5454+ branch?: string;
5555+ /** Prefetched language names. */
5656+ languages?: string[];
5757+};
5858+5959+/** NSID for Tangled Repo records. */
6060+export const TANGLED_REPO_COLLECTION = "sh.tangled.repo";
6161+6262+/**
6363+ * Resolves a Tangled Repo record and renders it with optional overrides while computing a canonical link.
6464+ *
6565+ * @param did - DID whose Tangled Repo should be fetched.
6666+ * @param rkey - Record key within the Tangled Repo collection.
6767+ * @param renderer - Optional component override that will receive injected props.
6868+ * @param fallback - Node rendered before the first load begins.
6969+ * @param loadingIndicator - Node rendered while the Tangled Repo is loading.
7070+ * @param colorScheme - Preferred color scheme for theming the renderer.
7171+ * @param showStarCount - Whether to show star count from backlinks. Defaults to true.
7272+ * @param branch - Branch to query for language information. Defaults to trying "main", then "master".
7373+ * @param languages - Prefetched language names (e.g., ['TypeScript', 'React']). When provided, skips fetching languages from the knot server.
7474+ * @returns A JSX subtree representing the Tangled Repo record with loading states handled.
7575+ */
7676+export const TangledRepo: React.FC<TangledRepoProps> = React.memo(({
7777+ did,
7878+ rkey,
7979+ record,
8080+ renderer,
8181+ fallback,
8282+ loadingIndicator,
8383+ colorScheme,
8484+ showStarCount = true,
8585+ branch,
8686+ languages,
8787+}) => {
8888+ const { tangledBaseUrl } = useAtProto();
8989+ const Comp: React.ComponentType<TangledRepoRendererInjectedProps> =
9090+ renderer ?? ((props) => <TangledRepoRenderer {...props} />);
9191+ const Wrapped: React.FC<{
9292+ record: TangledRepoRecord;
9393+ loading: boolean;
9494+ error?: Error;
9595+ }> = (props) => (
9696+ <Comp
9797+ {...props}
9898+ colorScheme={colorScheme}
9999+ did={did}
100100+ rkey={rkey}
101101+ canonicalUrl={`${tangledBaseUrl}/repos/${did}/${encodeURIComponent(rkey)}`}
102102+ showStarCount={showStarCount}
103103+ branch={branch}
104104+ languages={languages}
105105+ />
106106+ );
107107+108108+ if (record !== undefined) {
109109+ return (
110110+ <AtProtoRecord<TangledRepoRecord>
111111+ record={record}
112112+ renderer={Wrapped}
113113+ fallback={fallback}
114114+ loadingIndicator={loadingIndicator}
115115+ />
116116+ );
117117+ }
118118+119119+ return (
120120+ <AtProtoRecord<TangledRepoRecord>
121121+ did={did}
122122+ collection={TANGLED_REPO_COLLECTION}
123123+ rkey={rkey}
124124+ renderer={Wrapped}
125125+ fallback={fallback}
126126+ loadingIndicator={loadingIndicator}
127127+ />
128128+ );
129129+});
130130+131131+export default TangledRepo;
+1-1
lib/components/TangledString.tsx
···11import React from "react";
22import { AtProtoRecord } from "../core/AtProtoRecord";
33import { TangledStringRenderer } from "../renderers/TangledStringRenderer";
44-import type { TangledStringRecord } from "../renderers/TangledStringRenderer";
44+import type { TangledStringRecord } from "../types/tangled";
55import { useAtProto } from "../providers/AtProtoProvider";
6677/**
+163
lib/hooks/useBacklinks.ts
···11+import { useEffect, useState, useCallback, useRef } from "react";
22+33+/**
44+ * Individual backlink record returned by Microcosm Constellation.
55+ */
66+export interface BacklinkRecord {
77+ /** DID of the author who created the backlink. */
88+ did: string;
99+ /** Collection type of the backlink record (e.g., "sh.tangled.feed.star"). */
1010+ collection: string;
1111+ /** Record key of the backlink. */
1212+ rkey: string;
1313+}
1414+1515+/**
1616+ * Response from Microcosm Constellation API.
1717+ */
1818+export interface BacklinksResponse {
1919+ /** Total count of backlinks. */
2020+ total: number;
2121+ /** Array of backlink records. */
2222+ records: BacklinkRecord[];
2323+ /** Cursor for pagination (optional). */
2424+ cursor?: string;
2525+}
2626+2727+/**
2828+ * Parameters for fetching backlinks.
2929+ */
3030+export interface UseBacklinksParams {
3131+ /** The AT-URI subject to get backlinks for (e.g., "at://did:plc:xxx/sh.tangled.repo/yyy"). */
3232+ subject: string;
3333+ /** The source collection and path (e.g., "sh.tangled.feed.star:subject"). */
3434+ source: string;
3535+ /** Maximum number of results to fetch (default: 16, max: 100). */
3636+ limit?: number;
3737+ /** Base URL for the Microcosm Constellation API. */
3838+ constellationBaseUrl?: string;
3939+ /** Whether to automatically fetch backlinks on mount. */
4040+ enabled?: boolean;
4141+}
4242+4343+const DEFAULT_CONSTELLATION = "https://constellation.microcosm.blue";
4444+4545+/**
4646+ * Hook to fetch backlinks from Microcosm Constellation API.
4747+ *
4848+ * Backlinks are records that reference another record. For example,
4949+ * `sh.tangled.feed.star` records are backlinks to `sh.tangled.repo` records,
5050+ * representing users who have starred a repository.
5151+ *
5252+ * @param params - Configuration for fetching backlinks
5353+ * @returns Object containing backlinks data, loading state, error, and refetch function
5454+ *
5555+ * @example
5656+ * ```tsx
5757+ * const { backlinks, loading, error, count } = useBacklinks({
5858+ * subject: "at://did:plc:example/sh.tangled.repo/3k2aexample",
5959+ * source: "sh.tangled.feed.star:subject",
6060+ * });
6161+ * ```
6262+ */
6363+export function useBacklinks({
6464+ subject,
6565+ source,
6666+ limit = 16,
6767+ constellationBaseUrl = DEFAULT_CONSTELLATION,
6868+ enabled = true,
6969+}: UseBacklinksParams) {
7070+ const [backlinks, setBacklinks] = useState<BacklinkRecord[]>([]);
7171+ const [total, setTotal] = useState(0);
7272+ const [loading, setLoading] = useState(false);
7373+ const [error, setError] = useState<Error | undefined>(undefined);
7474+ const [cursor, setCursor] = useState<string | undefined>(undefined);
7575+ const abortControllerRef = useRef<AbortController | null>(null);
7676+7777+ const fetchBacklinks = useCallback(
7878+ async (signal?: AbortSignal) => {
7979+ if (!subject || !source || !enabled) return;
8080+8181+ try {
8282+ setLoading(true);
8383+ setError(undefined);
8484+8585+ const baseUrl = constellationBaseUrl.endsWith("/")
8686+ ? constellationBaseUrl.slice(0, -1)
8787+ : constellationBaseUrl;
8888+8989+ const params = new URLSearchParams({
9090+ subject: subject,
9191+ source: source,
9292+ limit: limit.toString(),
9393+ });
9494+9595+ const url = `${baseUrl}/xrpc/blue.microcosm.links.getBacklinks?${params}`;
9696+9797+ const response = await fetch(url, { signal });
9898+9999+ if (!response.ok) {
100100+ throw new Error(
101101+ `Failed to fetch backlinks: ${response.status} ${response.statusText}`,
102102+ );
103103+ }
104104+105105+ const data: BacklinksResponse = await response.json();
106106+ setBacklinks(data.records || []);
107107+ setTotal(data.total || 0);
108108+ setCursor(data.cursor);
109109+ } catch (err) {
110110+ if (err instanceof Error && err.name === "AbortError") {
111111+ // Ignore abort errors
112112+ return;
113113+ }
114114+ setError(
115115+ err instanceof Error ? err : new Error("Unknown error fetching backlinks"),
116116+ );
117117+ } finally {
118118+ setLoading(false);
119119+ }
120120+ },
121121+ [subject, source, limit, constellationBaseUrl, enabled],
122122+ );
123123+124124+ const refetch = useCallback(() => {
125125+ // Abort any in-flight request
126126+ if (abortControllerRef.current) {
127127+ abortControllerRef.current.abort();
128128+ }
129129+130130+ const controller = new AbortController();
131131+ abortControllerRef.current = controller;
132132+ fetchBacklinks(controller.signal);
133133+ }, [fetchBacklinks]);
134134+135135+ useEffect(() => {
136136+ if (!enabled) return;
137137+138138+ const controller = new AbortController();
139139+ abortControllerRef.current = controller;
140140+ fetchBacklinks(controller.signal);
141141+142142+ return () => {
143143+ controller.abort();
144144+ };
145145+ }, [fetchBacklinks, enabled]);
146146+147147+ return {
148148+ /** Array of backlink records. */
149149+ backlinks,
150150+ /** Whether backlinks are currently being fetched. */
151151+ loading,
152152+ /** Error if fetch failed. */
153153+ error,
154154+ /** Pagination cursor (not yet implemented for pagination). */
155155+ cursor,
156156+ /** Total count of backlinks from the API. */
157157+ total,
158158+ /** Total count of backlinks (alias for total). */
159159+ count: total,
160160+ /** Function to manually refetch backlinks. */
161161+ refetch,
162162+ };
163163+}
+104
lib/hooks/useRepoLanguages.ts
···11+import { useState, useEffect } from "react";
22+import type { RepoLanguagesResponse } from "../types/tangled";
33+44+export interface UseRepoLanguagesOptions {
55+ /** The knot server URL (e.g., "knot.gaze.systems") */
66+ knot?: string;
77+ /** DID of the repository owner */
88+ did?: string;
99+ /** Repository name */
1010+ repoName?: string;
1111+ /** Branch to query (defaults to trying "main", then "master") */
1212+ branch?: string;
1313+ /** Whether to enable the query */
1414+ enabled?: boolean;
1515+}
1616+1717+export interface UseRepoLanguagesResult {
1818+ /** Language data from the knot server */
1919+ data?: RepoLanguagesResponse;
2020+ /** Loading state */
2121+ loading: boolean;
2222+ /** Error state */
2323+ error?: Error;
2424+}
2525+2626+/**
2727+ * Hook to fetch repository language information from a Tangled knot server.
2828+ * If no branch supplied, tries "main" first, then falls back to "master".
2929+ */
3030+export function useRepoLanguages({
3131+ knot,
3232+ did,
3333+ repoName,
3434+ branch,
3535+ enabled = true,
3636+}: UseRepoLanguagesOptions): UseRepoLanguagesResult {
3737+ const [data, setData] = useState<RepoLanguagesResponse | undefined>();
3838+ const [loading, setLoading] = useState(false);
3939+ const [error, setError] = useState<Error | undefined>();
4040+4141+ useEffect(() => {
4242+ if (!enabled || !knot || !did || !repoName) {
4343+ return;
4444+ }
4545+4646+ let cancelled = false;
4747+4848+ const fetchLanguages = async (ref: string): Promise<boolean> => {
4949+ try {
5050+ const url = `https://${knot}/xrpc/sh.tangled.repo.languages?repo=${encodeURIComponent(`${did}/${repoName}`)}&ref=${encodeURIComponent(ref)}`;
5151+ const response = await fetch(url);
5252+5353+ if (!response.ok) {
5454+ return false;
5555+ }
5656+5757+ const result = await response.json();
5858+ if (!cancelled) {
5959+ setData(result);
6060+ setError(undefined);
6161+ }
6262+ return true;
6363+ } catch (err) {
6464+ return false;
6565+ }
6666+ };
6767+6868+ const fetchWithFallback = async () => {
6969+ setLoading(true);
7070+ setError(undefined);
7171+7272+ if (branch) {
7373+ const success = await fetchLanguages(branch);
7474+ if (!cancelled) {
7575+ if (!success) {
7676+ setError(new Error(`Failed to fetch languages for branch: ${branch}`));
7777+ }
7878+ setLoading(false);
7979+ }
8080+ } else {
8181+ // Try "main" first, then "master"
8282+ let success = await fetchLanguages("main");
8383+ if (!success && !cancelled) {
8484+ success = await fetchLanguages("master");
8585+ }
8686+8787+ if (!cancelled) {
8888+ if (!success) {
8989+ setError(new Error("Failed to fetch languages for main or master branch"));
9090+ }
9191+ setLoading(false);
9292+ }
9393+ }
9494+ };
9595+9696+ fetchWithFallback();
9797+9898+ return () => {
9999+ cancelled = true;
100100+ };
101101+ }, [knot, did, repoName, branch, enabled]);
102102+103103+ return { data, loading, error };
104104+}
+5
lib/index.ts
···1313export * from "./components/BlueskyProfile";
1414export * from "./components/BlueskyQuotePost";
1515export * from "./components/LeafletDocument";
1616+export * from "./components/TangledRepo";
1617export * from "./components/TangledString";
17181819// Hooks
1920export * from "./hooks/useAtProtoRecord";
2121+export * from "./hooks/useBacklinks";
2022export * from "./hooks/useBlob";
2123export * from "./hooks/useBlueskyAppview";
2224export * from "./hooks/useBlueskyProfile";
···2426export * from "./hooks/useLatestRecord";
2527export * from "./hooks/usePaginatedRecords";
2628export * from "./hooks/usePdsEndpoint";
2929+export * from "./hooks/useRepoLanguages";
27302831// Renderers
2932export * from "./renderers/BlueskyPostRenderer";
3033export * from "./renderers/BlueskyProfileRenderer";
3134export * from "./renderers/LeafletDocumentRenderer";
3535+export * from "./renderers/TangledRepoRenderer";
3236export * from "./renderers/TangledStringRenderer";
33373438// Types
3539export * from "./types/bluesky";
3640export * from "./types/leaflet";
4141+export * from "./types/tangled";
3742export * from "./types/theme";
38433944// Utilities