<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-bluesky-comments
  published:     2025-08-18
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Afficher les commentaires Bluesky sur Docusaurus, ainsi que le nombre de likes/reposts

> Intégrez les commentaires, les likes et le nombre de reposts Bluesky directement dans vos articles de blog Docusaurus.

---
![Afficher les commentaires Bluesky sur Docusaurus, ainsi que le nombre de likes/reposts](/img/v2/docusaurus_bluesky.webp)

**TL;DR**

Cet article est la deuxième partie de la série « Bluesky Docusaurus component ».
Il enrichit le composant créé dans la première partie en ajoutant de quoi
afficher les commentaires, les likes et le nombre de reposts Bluesky directement
sur votre blog Docusaurus. Pour cela, vous allez apprendre à relier vos articles
à des posts Bluesky via une clé de front matter, à mettre à jour le composant
React pour récupérer et afficher ces informations, et à configurer votre site
Docusaurus avec votre handle Bluesky.

Dans un [article précédent](/blog/docusaurus-bluesky-share), nous avons vu comment proposer, pour faire simple, un « Partager l'article sur Bluesky ».

Pas très interactif, non ? Voyons dans cet article comment faire le lien entre un article de blog Docusaurus et Bluesky.

**Quick Jump:** [Ce que vous allez obtenir](#what-youll-get) | [Tous les fichiers d'un coup d'œil](#all-files-at-a-glance)

## Ce que vous allez obtenir {#what-youll-get}

Une fois tout branché, chaque article reçoit un bouton « Like, share or comment on Bluesky », une unique **engagement facepile** qui fusionne likes, reposts et personnes ayant commenté dans une seule pile d'avatars dédoublonnée, et la liste des commentaires directement issus de votre post Bluesky — juste sous l'article, sans système de commentaires séparé à héberger.

Voici ce bloc, en direct sur trois articles différents de ce blog, avec trois niveaux d'engagement différents :

> Screenshot — http\://localhost/
>
> ![Engagement facepile avec cinq personnes impliquées : likes, reposts et avatars empilés](./images/engagement_facepile_many.webp)

> Screenshot — http\://localhost/
>
> ![Engagement facepile accompagnée du fil de commentaires vers lequel elle renvoie](./images/engagement_facepile_comments.webp)

> Screenshot — http\://localhost/
>
> ![Une engagement facepile plus modeste : deux likes, un repost](./images/engagement_facepile_repost.webp)

> **Information:**
>
> Remarquez qu'il n'y a pas de ligne « likers » séparée d'une ligne « commentateurs » : une personne qui a à la fois liké **et** commenté le post apparaît sous la forme d'un seul avatar, trié en premier — c'est tout l'intérêt de la fusion de la facepile décrite plus bas.

**Le workflow sera le suivant :**

1. Vous publiez votre article sur votre blog,

2. Vous partagez l'article sur Bluesky (en utilisant la fonctionnalité « Partager l'article », bien sûr),

3. Vous récupérez la **Bluesky Record key**, c'est-à-dire la toute dernière partie de l'URL du post Bluesky (quelque chose comme `3lun2qjuxc22r`),

4. Vous retournez éditer votre article Docusaurus et vous ajoutez une clé dans votre front matter YAML (le bloc YAML en haut de votre article). La clé à ajouter est `blueskyRecordKey: xxxx` où `xxxx` est votre **Bluesky Record key**.

5. Vous publiez à nouveau votre article mis à jour sur votre blog.

Voilà, à partir de maintenant, vous avez clairement établi le lien entre votre article et votre post Bluesky.

**Grâce à ce lien, nous pouvons :**

1. Afficher un bouton « Like, share or comment on Bluesky » : le visiteur peut alors sauter vers votre post Bluesky et liker, partager ou commenter votre article,

2. Afficher le nombre de likes et de reposts sous votre article et,

3. Afficher la liste des commentaires reçus sur votre post Bluesky et, s'il n'y en a aucun, afficher une zone d'*appel à l'action* pour encourager votre lecteur à partager son avis

Dans l'article précédent (voir [Create our own Docusaurus React component and provide a "Share on Bluesky" button](/blog/docusaurus-bluesky-share)), nous avons créé le fichier `src/components/Bluesky/index.tsx`. Éditez ce fichier et utilisez ce nouveau contenu :

## 1/6 La structure de notre composant Bluesky générique {#16-the-layout-of-our-bluesky-generic-component}

Voici la nouvelle structure Bluesky de notre composant :

**src/components/Bluesky/index.tsx**

```typescript
import BlueskyComments from "./comments";
import BlueskyLikes from "./likes";
import BlueskyPost from "./post";
import BlueskyShare from "./share";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useBlueskyEngagement, { useBlueskyRecordKey } from "./useBlueskyEngagement";
import type { BlueskyMetadata, EngagedPerson } from "./useBlueskyEngagement";

function engagementHeadline({
  blueskyRecordKey,
  loading,
  unavailable,
  engaged,
}: {
  blueskyRecordKey: string | null;
  loading: boolean;
  unavailable: boolean;
  engaged: EngagedPerson[];
}): string {
  if (!blueskyRecordKey) {
    return translate({
      id: "bluesky.heading.share",
      message: "🦋 Enjoyed this article? Share it on Bluesky",
    });
  }
  if (loading || unavailable) {
    // Same neutral copy for "still fetching" and "couldn't fetch" — never claim
    // "no one reacted yet" when the truth is just "couldn't check".
    return translate({
      id: "bluesky.heading.join",
      message: "🦋 Join the conversation on Bluesky",
    });
  }
  if (engaged.length === 0) {
    return translate({
      id: "bluesky.heading.first",
      message: "🦋 Be the first to react on Bluesky",
    });
  }
  const count = engaged.length;
  // Two whole sentences rather than a spliced "person is" / "people are": French agreement
  // reaches further than the verb ("personne en parle" / "personnes en parlent").
  return count === 1
    ? translate({
        id: "bluesky.heading.talkingOne",
        message: "🦋 1 person is already talking about this on Bluesky — join them",
      })
    : translate(
        {
          id: "bluesky.heading.talkingOther",
          message:
            "🦋 {count} people are already talking about this on Bluesky — join them",
        },
        { count },
      );
}

interface Props {
  metadata: BlueskyMetadata;
}

export default function Bluesky({ metadata }: Props) {
  // The frontmatter key always wins (manual override); otherwise it's looked up
  // from the account's own Bluesky post history, matched against this article's
  // URL — see useBlueskyRecordKey for why and how.
  const { recordKey, resolving } = useBlueskyRecordKey(metadata);
  const effectiveMetadata: BlueskyMetadata = {
    ...metadata,
    frontMatter: { ...metadata.frontMatter, blueskyRecordKey: recordKey ?? undefined },
  };
  const stats = useBlueskyEngagement(effectiveMetadata);

  // Still checking whether this article was already promoted — skip the frame
  // rather than flash a "Share on Bluesky" button that may disappear a moment
  // later once a match is found.
  if (resolving) return null;

  return (
    <div className={styles.blueskyContainer}>
      <span className={styles.blueskyHeadline}>
        {engagementHeadline({
          blueskyRecordKey: recordKey,
          loading: stats.loading,
          unavailable: stats.unavailable,
          engaged: stats.engaged,
        })}
      </span>

      {/* No Bluesky post found for this article: show a share button */}
      <BlueskyShare metadata={effectiveMetadata} />

      {/* A Bluesky post was found and its data actually loaded: show engagement UI.
          Hiding BlueskyPost too when unavailable — a resolved key can point to a
          post that was since deleted, and a link straight to a 404 is worse than
          no link. */}
      {!stats.unavailable && (
        <div className={styles.blueskyEngagementRow}>
          <BlueskyPost metadata={effectiveMetadata} />
          <BlueskyLikes stats={stats} />
        </div>
      )}

      <span className={styles.blueskyAccountNote}>
        <Translate id="bluesky.accountNote">
          {
            "Requires a free Bluesky account to like, repost or comment. This block talks to Bluesky's public API directly from your browser (no cookies, no sign-in) — that means your IP address reaches Bluesky's servers just by this section loading."
          }
        </Translate>
      </span>

      <BlueskyComments metadata={effectiveMetadata} />
    </div>
  );
}
```

> **Information:**
>
> Les lignes mises en évidence sont les nouvelles par rapport à la version précédente du fichier, si vous avez déjà suivi le premier tutoriel.

**Comme vous pouvez le voir, cette fois, nous prévoyons quatre composants :**

1. Un bouton de partage,

2. Un bouton Post (pour sauter vers votre post sur Bluesky),

3. Une zone likes (pour afficher le nombre de likes et de reposts de votre post sur Bluesky) et

4. Une zone pour afficher les commentaires reçus sur Bluesky

## 2/6 Le hook partagé de récupération des données {#26-the-shared-data-fetching-hook}

`index.tsx` (pour le texte d'accroche) et `likes.tsx` (pour les compteurs et la facepile) ont tous deux besoin des mêmes données Bluesky. Plutôt que de les récupérer deux fois, on centralise tout dans un hook personnalisé : `useBlueskyEngagement.ts`. Il résout la `blueskyRecordKey` (depuis votre front matter ou, si vous n'en avez pas défini, en comparant l'URL de l'article à votre propre historique de posts Bluesky) et récupère likes, reposts et commentaires en une seule passe.

**src/components/Bluesky/useBlueskyEngagement.ts**

```typescript
import { useState, useEffect } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls";

const MAX_ACTORS_PER_ENDPOINT = 20;
const FEED_CACHE_PREFIX = "bluesky-feed-cache:";
const FEED_CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_FEED_PAGES = 5;
const BLOCKED_LIST_CACHE_PREFIX = "bluesky-blocked-list-cache:";
const BLOCKED_LIST_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — list changes rarely

/** `docusaurus.config.js`'s `customFields.bluesky` — a custom field, typed `unknown` by
 * Docusaurus itself, so every reader casts against this shape. */
export interface BlueskySiteConfig {
  handle?: string;
  /**
   * AT URI of a Bluesky moderation list whose members are hidden from comments and the facepile.
   * Fetched at runtime from the public API (no auth required) and cached 1 h in sessionStorage —
   * adding a handle to the list takes effect on the next page load with no deploy needed.
   * Create a list at bsky.app → Lists, then copy its URI (at://did:plc:…/app.bsky.graph.list/…).
   */
  blockedList?: string;
  /** Compile-time override: handles blocked regardless of the Bluesky list (e.g. during deploy). */
  blockedHandles?: string[];
}

/** The subset of a blog post's `useBlogPost().metadata` every Bluesky file actually reads. */
export interface BlueskyMetadata {
  title?: string;
  permalink?: string;
  frontMatter?: {
    blueskyRecordKey?: string;
  };
}

export type EngagementAction = "liked" | "reposted" | "commented";

export interface EngagedPerson {
  did: string;
  handle: string;
  displayName: string;
  avatar?: string;
  actions: Set<EngagementAction>;
}

export interface EngagementStats {
  likes: number | null;
  reposts: number | null;
  engaged: EngagedPerson[];
  loading: boolean;
  unavailable: boolean;
}

export interface RecordKeyState {
  recordKey: string | null;
  resolving: boolean;
}

export interface BlueskyActor {
  did: string;
  handle: string;
  displayName?: string;
  avatar?: string;
}

export interface BlueskyFacetFeature {
  $type: string;
  uri?: string;
}

export interface BlueskyFacet {
  index: { byteStart: number; byteEnd: number };
  features: BlueskyFacetFeature[];
}

export interface BlueskyPostRecord {
  text: string;
  facets?: BlueskyFacet[];
}

export interface BlueskyEmbedExternal {
  $type: "app.bsky.embed.external#view";
  external: { uri: string; title: string; thumb?: string };
}

export interface BlueskyEmbedImages {
  $type: "app.bsky.embed.images#view";
  images: { fullsize: string; alt?: string }[];
}

export type BlueskyEmbed = BlueskyEmbedExternal | BlueskyEmbedImages | { $type: string };

export interface BlueskyPostView {
  uri: string;
  indexedAt: string;
  likeCount: number;
  repostCount?: number;
  author: BlueskyActor & { displayName: string };
  record: BlueskyPostRecord;
  embed?: BlueskyEmbed | null;
}

export interface BlueskyReplyNode {
  post: BlueskyPostView;
  replies?: BlueskyReplyNode[];
}

function flattenReplyAuthors(replies: BlueskyReplyNode[] | undefined): BlueskyActor[] {
  const authors: BlueskyActor[] = [];
  const walk = (arr: BlueskyReplyNode[] | undefined) => {
    if (!arr) return;
    for (const reply of arr) {
      authors.push(reply.post.author);
      walk(reply.replies);
    }
  };
  walk(replies);
  return authors;
}

function mergeEngagement(
  {
    likers = [],
    reposters = [],
    commenters = [],
  }: { likers?: BlueskyActor[]; reposters?: BlueskyActor[]; commenters?: BlueskyActor[] },
  ownerHandle: string | undefined,
  blockedHandles: Set<string> = new Set(),
): EngagedPerson[] {
  const byDid = new Map<string, EngagedPerson>();

  const record = (actor: BlueskyActor | undefined, action: EngagementAction) => {
    // Replying to your own readers isn't reader engagement — exclude the blog's own account.
    // Also exclude any handle explicitly blocked in the site config (e.g. spammers).
    if (!actor?.did || actor.handle === ownerHandle || blockedHandles.has(actor.handle))
      return;
    const entry = byDid.get(actor.did) || {
      did: actor.did,
      handle: actor.handle,
      displayName: actor.displayName || actor.handle,
      avatar: actor.avatar,
      actions: new Set<EngagementAction>(),
    };
    entry.actions.add(action);
    byDid.set(actor.did, entry);
  };

  likers.forEach((actor) => record(actor, "liked"));
  reposters.forEach((actor) => record(actor, "reposted"));
  commenters.forEach((actor) => record(actor, "commented"));

  // People engaged in more than one way surface first — they're the strongest social proof.
  return [...byDid.values()].sort((a, b) => b.actions.size - a.actions.size);
}

function normalizeUrl(url: string | null | undefined): string | null {
  if (!url) return null;
  return url.split("?")[0].replace(/\/+$/, "");
}

interface BlockedListResponse {
  items?: { subject?: { handle?: string } }[];
  cursor?: string;
}

// Fetches every handle in a Bluesky moderation list (pagination-aware) and caches
// the result in sessionStorage for 1 hour so browsing many articles doesn't re-fetch.
// Exported so BlueskyComments can share the same cache without a duplicate request.
export async function fetchBlockedList(listUri: string): Promise<Set<string>> {
  const cacheKey = BLOCKED_LIST_CACHE_PREFIX + listUri;
  try {
    const raw = sessionStorage.getItem(cacheKey);
    if (raw) {
      const { timestamp, handles } = JSON.parse(raw) as {
        timestamp: number;
        handles: string[];
      };
      if (Date.now() - timestamp <= BLOCKED_LIST_CACHE_TTL_MS) return new Set(handles);
    }
  } catch {
    /* private browsing or corrupt entry — just refetch */
  }

  const handles: string[] = [];
  let cursor: string | undefined;

  for (let page = 0; page < 10; page++) {
    const params = new URLSearchParams({ list: listUri, limit: "100" });
    if (cursor) params.set("cursor", cursor);
    const res = await fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.graph.getList?${params}`,
    );
    if (!res.ok) break;
    const data = (await res.json()) as BlockedListResponse;
    for (const item of data.items ?? []) {
      if (item.subject?.handle) handles.push(item.subject.handle);
    }
    cursor = data.cursor;
    if (!cursor) break;
  }

  try {
    sessionStorage.setItem(cacheKey, JSON.stringify({ timestamp: Date.now(), handles }));
  } catch {
    /* private browsing — cache miss next time, that's fine */
  }

  return new Set(handles);
}

type FeedIndex = Record<string, string>;

function readFeedCache(handle: string): FeedIndex | null {
  try {
    const raw = sessionStorage.getItem(FEED_CACHE_PREFIX + handle);
    if (!raw) return null;
    const { timestamp, urlToRecordKey } = JSON.parse(raw) as {
      timestamp: number;
      urlToRecordKey: FeedIndex;
    };
    if (Date.now() - timestamp > FEED_CACHE_TTL_MS) return null;
    return urlToRecordKey;
  } catch {
    return null;
  }
}

function writeFeedCache(handle: string, urlToRecordKey: FeedIndex): void {
  try {
    sessionStorage.setItem(
      FEED_CACHE_PREFIX + handle,
      JSON.stringify({ timestamp: Date.now(), urlToRecordKey }),
    );
  } catch {
    // Private browsing / storage disabled — resolution still works, just uncached.
  }
}

interface AuthorFeedItem {
  post: {
    uri: string;
    record?: {
      embed?: {
        $type?: string;
        external?: { uri?: string };
      };
    };
  };
}

interface AuthorFeedResponse {
  feed?: AuthorFeedItem[];
  cursor?: string;
}

// Builds a { normalizedArticleUrl: recordKey } map from the account's own post
// history, matching on the "external" link-card embed Bluesky attaches when a
// post links out (the same card the Share button in this component produces).
// Cached per tab session so browsing several un-tagged articles doesn't re-fetch
// the whole feed each time.
async function buildFeedIndex(handle: string): Promise<FeedIndex> {
  const cached = readFeedCache(handle);
  if (cached) return cached;

  const urlToRecordKey: FeedIndex = {};
  let cursor: string | undefined;

  for (let page = 0; page < MAX_FEED_PAGES; page += 1) {
    const params = new URLSearchParams({
      actor: handle,
      limit: "100",
      filter: "posts_no_replies",
    });
    if (cursor) params.set("cursor", cursor);

    const res = await fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?${params}`,
    );
    if (!res.ok) throw new Error("Failed to fetch author feed");
    const data = (await res.json()) as AuthorFeedResponse;

    for (const item of data.feed || []) {
      const embed = item.post?.record?.embed;
      const externalUri =
        embed?.$type === "app.bsky.embed.external" ? (embed.external?.uri ?? null) : null;
      const normalized = normalizeUrl(externalUri);
      if (normalized) urlToRecordKey[normalized] = item.post.uri.split("/").pop()!;
    }

    cursor = data.cursor;
    if (!cursor || !data.feed?.length) break;
  }

  writeFeedCache(handle, urlToRecordKey);
  return urlToRecordKey;
}

// Resolves the Bluesky record key for the current article. The frontmatter value
// always wins — it's the manual override needed after a slug rename, or to
// disambiguate an article re-promoted with a second post. Otherwise the key is
// looked up from the account's own post history. In a translated locale the
// article's own URL (`/fr/blog/x/`) is tried first, then the source-language one
// (`/blog/x/`): the author usually promotes only the English article, and French
// readers should join that conversation rather than see a "Share" button. A
// dedicated French post, if one exists, still wins. Never throws: any failure here
// (offline, blocked, no match found) just means no engagement UI is shown, the
// same as an article that was never promoted at all.
export function useBlueskyRecordKey(
  metadata: BlueskyMetadata | undefined,
): RecordKeyState {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const frontMatterKey = metadata?.frontMatter?.blueskyRecordKey;
  const { isDefaultLocale, sourceAbsoluteUrl } = useSourceLocaleUrls();

  // `permalink` comes from Docusaurus and already carries the locale's baseUrl
  // (`/fr/blog/x/`) — so `siteConfig.url + permalink` is correct as is and must
  // NOT be prefixed again. The source URL is the reverse: strip that baseUrl,
  // then resolve the rest against the source locale. Slugs are never translated,
  // so the remainder is the same in both locales.
  const permalink = metadata?.permalink;
  const currentUrl = permalink ? normalizeUrl(`${siteConfig.url}${permalink}`) : null;
  const sourceUrl =
    permalink && !isDefaultLocale && permalink.startsWith(siteConfig.baseUrl)
      ? normalizeUrl(sourceAbsoluteUrl(permalink.slice(siteConfig.baseUrl.length)))
      : null;

  const [state, setState] = useState<RecordKeyState>({
    recordKey: frontMatterKey || null,
    resolving: !frontMatterKey,
  });

  useEffect(() => {
    if (frontMatterKey) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- mirrors a prop change (article navigation), not derivable at render time
      setState({ recordKey: frontMatterKey, resolving: false });
      return;
    }
    if (!blueSkyConfig?.handle || !currentUrl) {
      setState({ recordKey: null, resolving: false });
      return;
    }

    let cancelled = false;

    (async () => {
      try {
        const index = await buildFeedIndex(blueSkyConfig.handle!);
        if (!cancelled)
          setState({
            recordKey: index[currentUrl] || (sourceUrl && index[sourceUrl]) || null,
            resolving: false,
          });
      } catch (e) {
        console.error("Bluesky record key lookup failed:", e);
        if (!cancelled) setState({ recordKey: null, resolving: false });
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [frontMatterKey, blueSkyConfig?.handle, currentUrl, sourceUrl]);

  return state;
}

interface LikesResponse {
  likes: { actor: BlueskyActor }[];
}

interface RepostedByResponse {
  repostedBy: BlueskyActor[];
}

async function fetchActors(
  endpoint: "getLikes" | "getRepostedBy",
  uri: string,
  listKey: "likes" | "repostedBy",
): Promise<BlueskyActor[]> {
  const url = `https://public.api.bsky.app/xrpc/app.bsky.feed.${endpoint}?uri=${encodeURIComponent(
    uri,
  )}&limit=${MAX_ACTORS_PER_ENDPOINT}`;

  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);

  const data = (await res.json()) as LikesResponse | RepostedByResponse;
  return listKey === "likes"
    ? (data as LikesResponse).likes.map(({ actor }) => actor)
    : (data as RepostedByResponse).repostedBy;
}

interface PostThreadResponse {
  thread: {
    post: { likeCount: number; repostCount: number; uri: string };
    replies?: BlueskyReplyNode[];
  };
}

// Shared by index.tsx (headline copy) and likes.tsx (counts + facepile) so the
// thread/likes/reposts fetches happen once per post, not once per consumer.
export default function useBlueskyEngagement(
  metadata: BlueskyMetadata | undefined,
): EngagementStats {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  const [stats, setStats] = useState<EngagementStats>({
    likes: null,
    reposts: null,
    engaged: [],
    loading: true,
    unavailable: false,
  });

  useEffect(() => {
    if (!blueskyRecordKey || !blueSkyConfig?.handle) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- resets stats when the post/config identity changes; the effect also fetches below, it's not derivable at render time
      setStats({
        likes: null,
        reposts: null,
        engaged: [],
        loading: false,
        unavailable: false,
      });
      return;
    }

    const fetchData = async () => {
      const postUri = `at://${blueSkyConfig.handle}/app.bsky.feed.post/${blueskyRecordKey}`;
      const threadUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(
        postUri,
      )}&depth=5`;

      // Fetch the thread and the moderation list in parallel — neither depends on the other.
      // The list is cached 1 h in sessionStorage, so after the first load it resolves instantly.
      try {
        const [threadRes, listBlocked] = await Promise.all([
          fetch(threadUrl),
          blueSkyConfig.blockedList
            ? fetchBlockedList(blueSkyConfig.blockedList).catch(() => new Set<string>())
            : Promise.resolve(new Set<string>()),
        ]);
        const blockedHandles = new Set([
          ...(blueSkyConfig.blockedHandles ?? []),
          ...listBlocked,
        ]);

        const res = threadRes;
        if (!res.ok) throw new Error("Failed to fetch post data");

        const data = (await res.json()) as PostThreadResponse;
        const { likeCount, repostCount, uri: resolvedUri } = data.thread.post;
        const commenters = flattenReplyAuthors(data.thread.replies);

        setStats({
          likes: likeCount,
          reposts: repostCount,
          engaged: mergeEngagement({ commenters }, blueSkyConfig.handle, blockedHandles),
          loading: false,
          unavailable: false,
        });

        // resolvedUri carries the post's DID (getLikes/getRepostedBy return nothing for a handle-based URI)
        const [likers, reposters] = await Promise.all([
          likeCount > 0 ? fetchActors("getLikes", resolvedUri, "likes") : [],
          repostCount > 0 ? fetchActors("getRepostedBy", resolvedUri, "repostedBy") : [],
        ]);

        setStats((prev) => ({
          ...prev,
          engaged: mergeEngagement(
            { likers, reposters, commenters },
            blueSkyConfig.handle,
            blockedHandles,
          ),
        }));
      } catch (e) {
        // Offline, blocked by the visitor (ad-blocker, DNS filtering), or Bluesky
        // itself down — never surface this as a JS error, just mark the data as
        // unavailable so the UI doesn't claim "no one reacted yet" instead.
        console.error("Error fetching Bluesky stats:", e);
        setStats({
          likes: null,
          reposts: null,
          engaged: [],
          loading: false,
          unavailable: true,
        });
      }
    };

    fetchData();
  }, [
    blueskyRecordKey,
    blueSkyConfig?.handle,
    blueSkyConfig?.blockedHandles,
    blueSkyConfig?.blockedList,
  ]);

  return stats;
}
```

## 3/6 Notre bouton « Like, share or comment » {#36-our-like-share-or-comment-button}

**src/components/Bluesky/post.tsx**

```typescript
import Icon from "./bluesky.svg";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import type { BlueskyMetadata, BlueskySiteConfig } from "./useBlueskyEngagement";

interface Props {
  metadata: BlueskyMetadata;
}

export default function BlueskyPost({ metadata }: Props) {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  if (!blueSkyConfig?.handle || !blueskyRecordKey) {
    return null;
  }

  const postUrl = `https://bsky.app/profile/${blueSkyConfig.handle}/post/${blueskyRecordKey}`;

  return (
    <a
      href={postUrl}
      target="_blank"
      rel="noopener noreferrer"
      className={styles.blueskyButton}
      aria-label={translate({
        id: "bluesky.post.ariaLabel",
        message: "See the post on Bluesky",
      })}
    >
      {/* alt dropped: SVGProps has no such prop — was a no-op DOM attribute even before this
          migration, and the link's own aria-label already names the icon. */}
      <Icon className={styles.blueskyLogo} />
      <Translate id="bluesky.post.label">Like, share or comment on Bluesky</Translate>
    </a>
  );
}
```

## 4/6 Notre composant pour obtenir le nombre de likes et de reposts {#46-our-component-for-getting-the-number-of-likes-and-reposts}

**src/components/Bluesky/likes.tsx**

```typescript
import styles from "./styles.module.css";
import { translate } from "@docusaurus/Translate";
import type {
  EngagedPerson,
  EngagementAction,
  EngagementStats,
} from "./useBlueskyEngagement";

const MAX_AVATARS = 10;

// Bluesky facets are keyed by "liked", "reposted", "commented" — a person can carry more than one.
function describeActions(actions: Set<EngagementAction>): string {
  // Each verb is translated on its own and the list is joined here. That holds in French too
  // ("a aimé, a repartagé et a commenté"), which is cheaper than translating all seven
  // combinations as whole sentences.
  const verbs: string[] = [];
  if (actions.has("liked")) {
    verbs.push(translate({ id: "bluesky.likes.verbLiked", message: "liked" }));
  }
  if (actions.has("reposted")) {
    verbs.push(translate({ id: "bluesky.likes.verbReposted", message: "reposted" }));
  }
  if (actions.has("commented")) {
    verbs.push(translate({ id: "bluesky.likes.verbCommented", message: "commented on" }));
  }

  if (verbs.length === 1) return verbs[0];
  const and = translate({ id: "bluesky.likes.and", message: "and" });
  return `${verbs.slice(0, -1).join(", ")} ${and} ${verbs[verbs.length - 1]}`;
}

interface Props {
  stats: EngagementStats;
}

export default function BlueskyLikes({ stats }: Props) {
  if (stats.loading || stats.likes === null) {
    return null;
  }

  const shown: EngagedPerson[] = stats.engaged.slice(0, MAX_AVATARS);
  const extraEngaged = stats.engaged.length - shown.length;

  return (
    <span className={styles.blueskyPostLikes}>
      <span
        className={styles.blueskyCommentLikes}
        title={translate(
          {
            id: "bluesky.likes.likesTitle",
            message: "The original post has {count} likes on Bluesky",
          },
          { count: stats.likes },
        )}
      >
        {stats.likes}
      </span>
      <span
        className={styles.blueskyCommentReposts}
        title={translate(
          {
            id: "bluesky.likes.repostsTitle",
            message: "The original post has been shared {count} times on Bluesky",
          },
          // Only `likes` is narrowed by the guard above; `reposts` can still be null here.
          { count: stats.reposts ?? 0 },
        )}
      >
        {stats.reposts}
      </span>
      {shown.length > 0 && (
        <span className={styles.blueskyLikersAvatars}>
          {shown.map((person, index) => (
            <a
              key={person.did}
              href={`https://bsky.app/profile/${person.handle}`}
              target="_blank"
              rel="noopener noreferrer"
              className={styles.blueskyLikersAvatarLink}
              style={{ zIndex: shown.length - index }}
              title={translate(
                {
                  id: "bluesky.likes.personTitle",
                  message: "{name} (@{handle}) {actions} this post",
                },
                {
                  name: person.displayName,
                  handle: person.handle,
                  actions: describeActions(person.actions),
                },
              )}
            >
              <img
                src={person.avatar}
                alt={translate(
                  { id: "bluesky.avatarAlt", message: "{name}'s avatar" },
                  { name: person.displayName },
                )}
                className={styles.blueskyLikersAvatar}
              />
            </a>
          ))}
          {extraEngaged > 0 && (
            <span className={styles.blueskyLikersAvatarMore}>+{extraEngaged}</span>
          )}
        </span>
      )}
    </span>
  );
}
```

> **Note:**
>
> Une version antérieure de ce composant n'affichait que les avatars des personnes ayant **liké** le post. C'était trompeur : un lecteur pouvait voir « 5 likes » à côté de seulement 3 avatars, parce que les reposts et les commentaires n'étaient pas comptés. La version actuelle récupère en parallèle les personnes ayant liké, reposté et commenté, les fusionne dans une seule `Map` indexée par le `did` Bluesky de la personne (donc la même personne qui like **et** commente n'apparaît qu'une fois), puis trie cette liste par nombre d'actions — quelqu'un qui a liké *et* commenté passe devant quelqu'un qui a seulement liké. Jusqu'à dix avatars sont affichés, le reste est replié dans un badge `+N`, et le tooltip de chaque avatar précise ce que la personne a réellement fait (`"liked and commented on this post"`).

## 5/6 Récupérer tous les commentaires {#56-retrieving-all-comments}

**src/components/Bluesky/comments.tsx**

```typescript
import { useState, useEffect, type ReactNode } from "react";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { fetchBlockedList } from "./useBlueskyEngagement";
import type {
  BlueskyEmbed,
  BlueskyMetadata,
  BlueskyPostRecord,
  BlueskyReplyNode,
  BlueskySiteConfig,
} from "./useBlueskyEngagement";

// Bluesky facet indices are UTF-8 byte offsets, not JS string indices.
// TextEncoder/TextDecoder ensures correct slicing for non-ASCII text (emoji, accents…).
function renderPostText(record: BlueskyPostRecord, isOwner: boolean): ReactNode {
  const text = record.text;
  const facets = record.facets || [];
  if (facets.length === 0) return text;

  const encoder = new TextEncoder();
  const decoder = new TextDecoder();
  const bytes = encoder.encode(text);

  const parts: ReactNode[] = [];
  let lastIndex = 0;

  facets.forEach((facet, idx) => {
    const start = facet.index.byteStart;
    const end = facet.index.byteEnd;

    const before = decoder.decode(bytes.slice(lastIndex, start));
    if (before) parts.push(before);

    const linkFeature = facet.features.find(
      (f) => f.$type === "app.bsky.richtext.facet#link",
    );
    if (linkFeature && isOwner) {
      // Site owner's links are trusted — render as a clickable anchor.
      parts.push(
        <a
          key={`link-${idx}`}
          href={linkFeature.uri}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          {decoder.decode(bytes.slice(start, end))}
        </a>,
      );
    } else {
      // Other authors' links are unvetted (spam risk).
      // Render the anchor text only — the original is reachable via "View comment".
      parts.push(decoder.decode(bytes.slice(start, end)));
    }

    lastIndex = end;
  });

  if (lastIndex < bytes.length) {
    parts.push(decoder.decode(bytes.slice(lastIndex)));
  }

  return parts;
}

function renderEmbed(embed: BlueskyEmbed | null | undefined): ReactNode {
  if (!embed) return null;

  // Narrowed by property presence, not by `$type` equality: `BlueskyEmbed`'s catch-all
  // member types `$type` as a plain `string` (any other embed kind the API can return —
  // video, record quote, …), so a literal-equality check can't exclude it and `embed.external`
  // wouldn't narrow.
  if ("external" in embed) {
    const { uri, title, thumb } = embed.external;

    return (
      <a
        href={uri}
        target="_blank"
        rel="noopener noreferrer nofollow"
        className={styles.blueskyCommentEmbed}
      >
        {thumb && <img src={thumb} alt="" className={styles.blueskyCommentEmbedThumb} />}
        <div className={styles.blueskyCommentEmbedContent}>
          <strong>{title}</strong>
        </div>
      </a>
    );
  }

  if ("images" in embed) {
    return (
      <div className={styles.blueskyCommentImages}>
        {embed.images.map((image, index) => (
          <img
            key={index}
            src={image.fullsize}
            alt={
              image.alt ||
              translate({ id: "bluesky.embeddedImage", message: "Embedded image" })
            }
            className={styles.blueskyCommentImage}
          />
        ))}
      </div>
    );
  }

  return null;
}

interface FlattenedReply extends BlueskyReplyNode {
  depth: number;
}

function BlueskyComment({
  reply,
  ownerHandle,
}: {
  reply: FlattenedReply;
  ownerHandle?: string;
}) {
  const isOwner = !!ownerHandle && reply.post.author.handle === ownerHandle;
  const recordKey = reply.post.uri.split("/").pop();
  const profileUrl = `https://bsky.app/profile/${reply.post.author.handle}`;
  const commentUrl = `https://bsky.app/profile/${reply.post.author.handle}/post/${recordKey}`;
  const date = new Date(reply.post.indexedAt).toLocaleString(undefined, {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });

  return (
    <div
      className={styles.blueskyCommentContainer}
      style={{ paddingLeft: `${1.5 + reply.depth * 1.5}rem` }}
    >
      <div className={`${styles.blueskyCommentHeader} mb-2 flex items-center`}>
        <a href={profileUrl} target="_blank" rel="noopener noreferrer nofollow">
          <img
            src={reply.post.author.avatar}
            alt={translate(
              { id: "bluesky.avatarAlt", message: "{name}'s avatar" },
              { name: reply.post.author.displayName },
            )}
            className={styles.blueskyCommentAvatar}
          />
        </a>
        <div className={styles.blueskyCommentAuthorInfos}>
          <a
            href={profileUrl}
            target="_blank"
            rel="noopener noreferrer nofollow"
            className={styles.blueskyCommentAuthorDisplayName}
          >
            {reply.post.author.displayName}
          </a>
          <span className={styles.blueskyCommentAuthorHandle}>
            @{reply.post.author.handle}
          </span>
        </div>
      </div>

      <span className={styles.blueskyCommentDate}>{date}</span>

      <p className={styles.blueskyCommentText}>
        {renderPostText(reply.post.record, isOwner)}
      </p>

      {renderEmbed(reply.post.embed)}

      <div className={styles.blueskyCommentFooter}>
        <span className={styles.blueskyCommentLikes}>{reply.post.likeCount}</span>
        <span className={styles.blueskyCommentReposts}>
          {reply.post.repostCount || 0}
        </span>
        <a
          className={styles.blueskyCommentLink}
          href={commentUrl}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          <Translate id="bluesky.comments.view">View comment</Translate>
        </a>
      </div>
    </div>
  );
}

interface PostThreadResponse {
  thread: {
    replies?: BlueskyReplyNode[];
  };
}

interface Props {
  metadata: BlueskyMetadata;
}

export default function BlueskyComments({ metadata }: Props) {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  const [comments, setComments] = useState<FlattenedReply[] | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    if (!blueskyRecordKey || !blueSkyConfig?.handle) return;

    const fetchComments = async () => {
      try {
        const postUri = `at://${blueSkyConfig.handle}/app.bsky.feed.post/${blueskyRecordKey}`;
        const url =
          "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=5&uri=" +
          encodeURIComponent(postUri);

        // Fetch thread and moderation list in parallel — shares the sessionStorage cache
        // with useBlueskyEngagement so the second caller resolves instantly.
        const [res, listBlocked] = await Promise.all([
          fetch(url),
          blueSkyConfig?.blockedList
            ? fetchBlockedList(blueSkyConfig.blockedList).catch(() => new Set<string>())
            : Promise.resolve(new Set<string>()),
        ]);
        if (!res.ok) throw new Error("Failed to fetch post thread");
        const data = (await res.json()) as PostThreadResponse;

        const blocked = new Set([
          ...(blueSkyConfig?.blockedHandles ?? []),
          ...listBlocked,
        ]);
        const allComments: FlattenedReply[] = [];
        const flattenReplies = (arr: BlueskyReplyNode[] | undefined, depth: number) => {
          if (!arr) return;
          for (const r of arr) {
            // Skip blocked handles (e.g. spammers hidden on bsky.app but still
            // returned by the public API). Sub-replies are still walked so that
            // legitimate replies to a spam comment remain visible.
            if (!blocked.has(r.post.author.handle)) {
              allComments.push({ ...r, depth });
            }
            if (r.replies) flattenReplies(r.replies, depth + 1);
          }
        };

        if (data.thread?.replies) {
          flattenReplies(data.thread.replies, 0);
        }

        setComments(allComments);
      } catch (err) {
        // Offline, blocked by the visitor, or Bluesky unreachable: stay silent
        // rather than surface a raw error — same outcome as no comments to show.
        console.error(err);
        setError(true);
      }
    };
    fetchComments();
  }, [
    blueskyRecordKey,
    blueSkyConfig?.handle,
    blueSkyConfig?.blockedHandles,
    blueSkyConfig?.blockedList,
  ]);

  if (!blueskyRecordKey || error) return null;
  if (comments === null) {
    return (
      <p>
        <Translate id="bluesky.comments.loading">Loading comments…</Translate>
      </p>
    );
  }

  const postUrl = `https://bsky.app/profile/${blueSkyConfig?.handle}/post/${blueskyRecordKey}`;

  if (comments.length === 0)
    return (
      <p className={styles.blueskyNoCommentYet}>
        <Translate id="bluesky.comments.none">
          This post is waiting for its first comment.
        </Translate>
        &nbsp;
        <a
          className={styles.blueskyNoCommentYetCTA}
          href={postUrl}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          <Translate id="bluesky.comments.cta">Share your thoughts!</Translate>
        </a>
      </p>
    );

  return (
    <div className={styles.blueskyCommentsContainer}>
      <h3>
        <Translate id="bluesky.comments.heading" values={{ count: comments.length }}>
          {"💬 Comments from Bluesky ({count})"}
        </Translate>
      </h3>
      {comments.map((reply) => (
        <BlueskyComment
          key={reply.post.uri}
          reply={reply}
          ownerHandle={blueSkyConfig?.handle}
        />
      ))}
    </div>
  );
}
```

## 6/6 Notre nouveau CSS {#66-our-new-css}

**src/components/Bluesky/styles.module.css**

```css
.blueskyLogo {
  width: 20px;
  height: 20px;
}

.blueskyContainer {
  margin: 2rem 0 0.5rem;
  padding: 1.1rem 1.25rem 1.4rem;
  border: 1px solid var(--ifm-color-emphasis-200);
  border-radius: 8px;
  background: var(--ifm-background-surface-color);
}

.blueskyHeadline {
  display: block;
  font-weight: 500;
  color: var(--ifm-color-content);
  margin-bottom: 0.75rem;
}

.blueskyAccountNote {
  display: block;
  margin-top: 0.6rem;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyCommentsContainer {
  border: 1px solid var(--ifm-color-emphasis-200);
  margin-top: 1rem;
  padding: 0.4rem 0.9rem;
  margin-bottom: 1.25rem;
}

.blueskyCommentContainer {
  margin-bottom: 1rem;
  background-color: var(--ifm-color-emphasis-100);
  padding: 0.8rem;
  border-radius: 6px;
  border-left: 2px solid var(--ifm-color-emphasis-300);
  color: var(--ifm-font-color-base);
}

.blueskyCommentHeader {
  display: flex;
  align-items: center;
  gap: 10px;
}

.blueskyCommentAuthorInfos {
  display: flex;
  flex-direction: column;
}

.blueskyCommentAvatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  margin-right: 0.5rem;
}

.blueskyCommentAuthorDisplayName {
  margin: 0;
  font-weight: bold;
}

.blueskyCommentAuthorHandle {
  margin: 0;
  font-size: smaller;
  font-weight: normal;
  color: var(--ifm-color-emphasis-600);
  display: block;
}

.blueskyCommentDate {
  margin-left: auto;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyCommentText {
  margin: 0.3rem 0 0;
}

.blueskyCommentLikes::before {
  content: "❤️\a0";
}

.blueskyCommentLikes::after {
  content: " ";
}

.blueskyCommentReposts::before {
  content: "🔁\a0";
}

.blueskyCommentReposts::after {
  content: " ";
}

.blueskyCommentLink::before {
  content: "→\a0";
}

.blueskyCommentLink::after {
  content: " ";
}

.blueskyCommentLink {
  margin-right: 0.25rem;
  display: inline-flex;
  align-items: center;
  color: var(--ifm-color-emphasis-600);
  text-decoration: none;
  margin-top: 0.25rem;
}

img.blueskyCommentEmbedThumb {
  max-height: 250px;
}

.blueskyCommentImages {
  margin-top: 0.5rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.blueskyCommentImage {
  max-width: 100%;
  height: auto;
  border-radius: 0.25rem;
  border: 1px solid var(--ifm-color-emphasis-300);
}

.blueskyNoCommentYet {
  color: var(--ifm-color-emphasis-600);
  font-size: 1rem;
  line-height: 1.5;
  text-align: center;
  padding: 20px;
  background-color: var(--ifm-color-emphasis-100);
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 8px;
  margin: 20px 0;
}

.blueskyNoCommentYetCTA {
  font-weight: bold;
  color: var(--ifm-color-primary-dark);
  cursor: pointer;
  text-decoration: underline;
}

[data-theme="dark"] .blueskyNoCommentYetCTA {
  color: var(--ifm-color-primary-light);
}

.blueskyPostLikes {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.blueskyLikersAvatars {
  display: inline-flex;
  align-items: center;
  margin-left: 0.4rem;
}

.blueskyLikersAvatarLink {
  margin-left: -1rem;
  line-height: 0;
}

.blueskyLikersAvatarLink:first-child {
  margin-left: 0;
}

.blueskyLikersAvatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid var(--ifm-background-color);
  display: block;
}

.blueskyLikersAvatarMore {
  margin-left: 0.4rem;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyEngagementRow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
}

.blueskyButton {
  align-items: center;
  background-color: var(--ifm-color-emphasis-200);
  border-radius: 6px;
  border: 1px solid var(--ifm-color-emphasis-300);
  box-shadow: none;
  color: var(--ifm-font-color-base);
  display: inline-flex;
  font-size: 0.9rem;
  font-weight: 400;
  gap: 0.4rem;
  margin-bottom: 0.8rem;
  margin-right: 0.4rem;
  padding: 0.4rem 0.9rem;
  text-decoration: none;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}

.blueskyButton:hover {
  background-color: var(--ifm-color-primary);
  border-color: var(--ifm-color-primary);
  color: white;
}
```

## Mettre à jour le fichier de configuration Docusaurus {#update-the-docusaurus-configuration-file}

On y est presque. Pour pouvoir récupérer les likes, partages et commentaires de votre compte Bluesky, vous devez indiquer votre handle Bluesky au composant.

Vous devez éditer le fichier `docusaurus.config.js` et ajouter une nouvelle section appelée `customFields`, comme illustré ci-dessous :

**docusaurus.config.js**

```javascript
// Partial file; illustration of what you've to do

// [...]

const config = {

  // [...]

  customFields: {
    bluesky: {
      // This is the Bluesky handle as displayed in your Bluesky profile page
      handle: "your-bluesky-handle",
    },
  }

}
```

> **Danger:**
>
> Évidemment, utilisez le vôtre ; pas le mien ;-)

## Tous les fichiers d'un coup d'œil {#all-files-at-a-glance}

Voici l'ensemble des fichiers touchés par cet article — la mise à jour de `index.tsx`, et les quatre nouveaux fichiers (`useBlueskyEngagement.ts`, `post.tsx`, `likes.tsx`, `comments.tsx`) plus le CSS rafraîchi. Utilisez le bouton ci-dessous pour les copier tous d'un coup, ou téléchargez-les sous forme de ZIP prêt à extraire :

**Project: Bluesky engagement**

> **Note:**
>
> Ces fichiers remplacent ceux créés dans le [premier article](/blog/docusaurus-bluesky-share).
> N'oubliez pas d'ajouter aussi le bloc `customFields.bluesky` à votre `docusaurus.config.js` (voir
> ci-dessus), puis de redémarrer votre serveur de développement Docusaurus.

**src/components/Bluesky/index.tsx**

```typescript
import BlueskyComments from "./comments";
import BlueskyLikes from "./likes";
import BlueskyPost from "./post";
import BlueskyShare from "./share";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useBlueskyEngagement, { useBlueskyRecordKey } from "./useBlueskyEngagement";
import type { BlueskyMetadata, EngagedPerson } from "./useBlueskyEngagement";

function engagementHeadline({
  blueskyRecordKey,
  loading,
  unavailable,
  engaged,
}: {
  blueskyRecordKey: string | null;
  loading: boolean;
  unavailable: boolean;
  engaged: EngagedPerson[];
}): string {
  if (!blueskyRecordKey) {
    return translate({
      id: "bluesky.heading.share",
      message: "🦋 Enjoyed this article? Share it on Bluesky",
    });
  }
  if (loading || unavailable) {
    // Same neutral copy for "still fetching" and "couldn't fetch" — never claim
    // "no one reacted yet" when the truth is just "couldn't check".
    return translate({
      id: "bluesky.heading.join",
      message: "🦋 Join the conversation on Bluesky",
    });
  }
  if (engaged.length === 0) {
    return translate({
      id: "bluesky.heading.first",
      message: "🦋 Be the first to react on Bluesky",
    });
  }
  const count = engaged.length;
  // Two whole sentences rather than a spliced "person is" / "people are": French agreement
  // reaches further than the verb ("personne en parle" / "personnes en parlent").
  return count === 1
    ? translate({
        id: "bluesky.heading.talkingOne",
        message: "🦋 1 person is already talking about this on Bluesky — join them",
      })
    : translate(
        {
          id: "bluesky.heading.talkingOther",
          message:
            "🦋 {count} people are already talking about this on Bluesky — join them",
        },
        { count },
      );
}

interface Props {
  metadata: BlueskyMetadata;
}

export default function Bluesky({ metadata }: Props) {
  // The frontmatter key always wins (manual override); otherwise it's looked up
  // from the account's own Bluesky post history, matched against this article's
  // URL — see useBlueskyRecordKey for why and how.
  const { recordKey, resolving } = useBlueskyRecordKey(metadata);
  const effectiveMetadata: BlueskyMetadata = {
    ...metadata,
    frontMatter: { ...metadata.frontMatter, blueskyRecordKey: recordKey ?? undefined },
  };
  const stats = useBlueskyEngagement(effectiveMetadata);

  // Still checking whether this article was already promoted — skip the frame
  // rather than flash a "Share on Bluesky" button that may disappear a moment
  // later once a match is found.
  if (resolving) return null;

  return (
    <div className={styles.blueskyContainer}>
      <span className={styles.blueskyHeadline}>
        {engagementHeadline({
          blueskyRecordKey: recordKey,
          loading: stats.loading,
          unavailable: stats.unavailable,
          engaged: stats.engaged,
        })}
      </span>

      {/* No Bluesky post found for this article: show a share button */}
      <BlueskyShare metadata={effectiveMetadata} />

      {/* A Bluesky post was found and its data actually loaded: show engagement UI.
          Hiding BlueskyPost too when unavailable — a resolved key can point to a
          post that was since deleted, and a link straight to a 404 is worse than
          no link. */}
      {!stats.unavailable && (
        <div className={styles.blueskyEngagementRow}>
          <BlueskyPost metadata={effectiveMetadata} />
          <BlueskyLikes stats={stats} />
        </div>
      )}

      <span className={styles.blueskyAccountNote}>
        <Translate id="bluesky.accountNote">
          {
            "Requires a free Bluesky account to like, repost or comment. This block talks to Bluesky's public API directly from your browser (no cookies, no sign-in) — that means your IP address reaches Bluesky's servers just by this section loading."
          }
        </Translate>
      </span>

      <BlueskyComments metadata={effectiveMetadata} />
    </div>
  );
}
```

**src/components/Bluesky/useBlueskyEngagement.ts**

```typescript
import { useState, useEffect } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls";

const MAX_ACTORS_PER_ENDPOINT = 20;
const FEED_CACHE_PREFIX = "bluesky-feed-cache:";
const FEED_CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_FEED_PAGES = 5;
const BLOCKED_LIST_CACHE_PREFIX = "bluesky-blocked-list-cache:";
const BLOCKED_LIST_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — list changes rarely

/** `docusaurus.config.js`'s `customFields.bluesky` — a custom field, typed `unknown` by
 * Docusaurus itself, so every reader casts against this shape. */
export interface BlueskySiteConfig {
  handle?: string;
  /**
   * AT URI of a Bluesky moderation list whose members are hidden from comments and the facepile.
   * Fetched at runtime from the public API (no auth required) and cached 1 h in sessionStorage —
   * adding a handle to the list takes effect on the next page load with no deploy needed.
   * Create a list at bsky.app → Lists, then copy its URI (at://did:plc:…/app.bsky.graph.list/…).
   */
  blockedList?: string;
  /** Compile-time override: handles blocked regardless of the Bluesky list (e.g. during deploy). */
  blockedHandles?: string[];
}

/** The subset of a blog post's `useBlogPost().metadata` every Bluesky file actually reads. */
export interface BlueskyMetadata {
  title?: string;
  permalink?: string;
  frontMatter?: {
    blueskyRecordKey?: string;
  };
}

export type EngagementAction = "liked" | "reposted" | "commented";

export interface EngagedPerson {
  did: string;
  handle: string;
  displayName: string;
  avatar?: string;
  actions: Set<EngagementAction>;
}

export interface EngagementStats {
  likes: number | null;
  reposts: number | null;
  engaged: EngagedPerson[];
  loading: boolean;
  unavailable: boolean;
}

export interface RecordKeyState {
  recordKey: string | null;
  resolving: boolean;
}

export interface BlueskyActor {
  did: string;
  handle: string;
  displayName?: string;
  avatar?: string;
}

export interface BlueskyFacetFeature {
  $type: string;
  uri?: string;
}

export interface BlueskyFacet {
  index: { byteStart: number; byteEnd: number };
  features: BlueskyFacetFeature[];
}

export interface BlueskyPostRecord {
  text: string;
  facets?: BlueskyFacet[];
}

export interface BlueskyEmbedExternal {
  $type: "app.bsky.embed.external#view";
  external: { uri: string; title: string; thumb?: string };
}

export interface BlueskyEmbedImages {
  $type: "app.bsky.embed.images#view";
  images: { fullsize: string; alt?: string }[];
}

export type BlueskyEmbed = BlueskyEmbedExternal | BlueskyEmbedImages | { $type: string };

export interface BlueskyPostView {
  uri: string;
  indexedAt: string;
  likeCount: number;
  repostCount?: number;
  author: BlueskyActor & { displayName: string };
  record: BlueskyPostRecord;
  embed?: BlueskyEmbed | null;
}

export interface BlueskyReplyNode {
  post: BlueskyPostView;
  replies?: BlueskyReplyNode[];
}

function flattenReplyAuthors(replies: BlueskyReplyNode[] | undefined): BlueskyActor[] {
  const authors: BlueskyActor[] = [];
  const walk = (arr: BlueskyReplyNode[] | undefined) => {
    if (!arr) return;
    for (const reply of arr) {
      authors.push(reply.post.author);
      walk(reply.replies);
    }
  };
  walk(replies);
  return authors;
}

function mergeEngagement(
  {
    likers = [],
    reposters = [],
    commenters = [],
  }: { likers?: BlueskyActor[]; reposters?: BlueskyActor[]; commenters?: BlueskyActor[] },
  ownerHandle: string | undefined,
  blockedHandles: Set<string> = new Set(),
): EngagedPerson[] {
  const byDid = new Map<string, EngagedPerson>();

  const record = (actor: BlueskyActor | undefined, action: EngagementAction) => {
    // Replying to your own readers isn't reader engagement — exclude the blog's own account.
    // Also exclude any handle explicitly blocked in the site config (e.g. spammers).
    if (!actor?.did || actor.handle === ownerHandle || blockedHandles.has(actor.handle))
      return;
    const entry = byDid.get(actor.did) || {
      did: actor.did,
      handle: actor.handle,
      displayName: actor.displayName || actor.handle,
      avatar: actor.avatar,
      actions: new Set<EngagementAction>(),
    };
    entry.actions.add(action);
    byDid.set(actor.did, entry);
  };

  likers.forEach((actor) => record(actor, "liked"));
  reposters.forEach((actor) => record(actor, "reposted"));
  commenters.forEach((actor) => record(actor, "commented"));

  // People engaged in more than one way surface first — they're the strongest social proof.
  return [...byDid.values()].sort((a, b) => b.actions.size - a.actions.size);
}

function normalizeUrl(url: string | null | undefined): string | null {
  if (!url) return null;
  return url.split("?")[0].replace(/\/+$/, "");
}

interface BlockedListResponse {
  items?: { subject?: { handle?: string } }[];
  cursor?: string;
}

// Fetches every handle in a Bluesky moderation list (pagination-aware) and caches
// the result in sessionStorage for 1 hour so browsing many articles doesn't re-fetch.
// Exported so BlueskyComments can share the same cache without a duplicate request.
export async function fetchBlockedList(listUri: string): Promise<Set<string>> {
  const cacheKey = BLOCKED_LIST_CACHE_PREFIX + listUri;
  try {
    const raw = sessionStorage.getItem(cacheKey);
    if (raw) {
      const { timestamp, handles } = JSON.parse(raw) as {
        timestamp: number;
        handles: string[];
      };
      if (Date.now() - timestamp <= BLOCKED_LIST_CACHE_TTL_MS) return new Set(handles);
    }
  } catch {
    /* private browsing or corrupt entry — just refetch */
  }

  const handles: string[] = [];
  let cursor: string | undefined;

  for (let page = 0; page < 10; page++) {
    const params = new URLSearchParams({ list: listUri, limit: "100" });
    if (cursor) params.set("cursor", cursor);
    const res = await fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.graph.getList?${params}`,
    );
    if (!res.ok) break;
    const data = (await res.json()) as BlockedListResponse;
    for (const item of data.items ?? []) {
      if (item.subject?.handle) handles.push(item.subject.handle);
    }
    cursor = data.cursor;
    if (!cursor) break;
  }

  try {
    sessionStorage.setItem(cacheKey, JSON.stringify({ timestamp: Date.now(), handles }));
  } catch {
    /* private browsing — cache miss next time, that's fine */
  }

  return new Set(handles);
}

type FeedIndex = Record<string, string>;

function readFeedCache(handle: string): FeedIndex | null {
  try {
    const raw = sessionStorage.getItem(FEED_CACHE_PREFIX + handle);
    if (!raw) return null;
    const { timestamp, urlToRecordKey } = JSON.parse(raw) as {
      timestamp: number;
      urlToRecordKey: FeedIndex;
    };
    if (Date.now() - timestamp > FEED_CACHE_TTL_MS) return null;
    return urlToRecordKey;
  } catch {
    return null;
  }
}

function writeFeedCache(handle: string, urlToRecordKey: FeedIndex): void {
  try {
    sessionStorage.setItem(
      FEED_CACHE_PREFIX + handle,
      JSON.stringify({ timestamp: Date.now(), urlToRecordKey }),
    );
  } catch {
    // Private browsing / storage disabled — resolution still works, just uncached.
  }
}

interface AuthorFeedItem {
  post: {
    uri: string;
    record?: {
      embed?: {
        $type?: string;
        external?: { uri?: string };
      };
    };
  };
}

interface AuthorFeedResponse {
  feed?: AuthorFeedItem[];
  cursor?: string;
}

// Builds a { normalizedArticleUrl: recordKey } map from the account's own post
// history, matching on the "external" link-card embed Bluesky attaches when a
// post links out (the same card the Share button in this component produces).
// Cached per tab session so browsing several un-tagged articles doesn't re-fetch
// the whole feed each time.
async function buildFeedIndex(handle: string): Promise<FeedIndex> {
  const cached = readFeedCache(handle);
  if (cached) return cached;

  const urlToRecordKey: FeedIndex = {};
  let cursor: string | undefined;

  for (let page = 0; page < MAX_FEED_PAGES; page += 1) {
    const params = new URLSearchParams({
      actor: handle,
      limit: "100",
      filter: "posts_no_replies",
    });
    if (cursor) params.set("cursor", cursor);

    const res = await fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?${params}`,
    );
    if (!res.ok) throw new Error("Failed to fetch author feed");
    const data = (await res.json()) as AuthorFeedResponse;

    for (const item of data.feed || []) {
      const embed = item.post?.record?.embed;
      const externalUri =
        embed?.$type === "app.bsky.embed.external" ? (embed.external?.uri ?? null) : null;
      const normalized = normalizeUrl(externalUri);
      if (normalized) urlToRecordKey[normalized] = item.post.uri.split("/").pop()!;
    }

    cursor = data.cursor;
    if (!cursor || !data.feed?.length) break;
  }

  writeFeedCache(handle, urlToRecordKey);
  return urlToRecordKey;
}

// Resolves the Bluesky record key for the current article. The frontmatter value
// always wins — it's the manual override needed after a slug rename, or to
// disambiguate an article re-promoted with a second post. Otherwise the key is
// looked up from the account's own post history. In a translated locale the
// article's own URL (`/fr/blog/x/`) is tried first, then the source-language one
// (`/blog/x/`): the author usually promotes only the English article, and French
// readers should join that conversation rather than see a "Share" button. A
// dedicated French post, if one exists, still wins. Never throws: any failure here
// (offline, blocked, no match found) just means no engagement UI is shown, the
// same as an article that was never promoted at all.
export function useBlueskyRecordKey(
  metadata: BlueskyMetadata | undefined,
): RecordKeyState {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const frontMatterKey = metadata?.frontMatter?.blueskyRecordKey;
  const { isDefaultLocale, sourceAbsoluteUrl } = useSourceLocaleUrls();

  // `permalink` comes from Docusaurus and already carries the locale's baseUrl
  // (`/fr/blog/x/`) — so `siteConfig.url + permalink` is correct as is and must
  // NOT be prefixed again. The source URL is the reverse: strip that baseUrl,
  // then resolve the rest against the source locale. Slugs are never translated,
  // so the remainder is the same in both locales.
  const permalink = metadata?.permalink;
  const currentUrl = permalink ? normalizeUrl(`${siteConfig.url}${permalink}`) : null;
  const sourceUrl =
    permalink && !isDefaultLocale && permalink.startsWith(siteConfig.baseUrl)
      ? normalizeUrl(sourceAbsoluteUrl(permalink.slice(siteConfig.baseUrl.length)))
      : null;

  const [state, setState] = useState<RecordKeyState>({
    recordKey: frontMatterKey || null,
    resolving: !frontMatterKey,
  });

  useEffect(() => {
    if (frontMatterKey) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- mirrors a prop change (article navigation), not derivable at render time
      setState({ recordKey: frontMatterKey, resolving: false });
      return;
    }
    if (!blueSkyConfig?.handle || !currentUrl) {
      setState({ recordKey: null, resolving: false });
      return;
    }

    let cancelled = false;

    (async () => {
      try {
        const index = await buildFeedIndex(blueSkyConfig.handle!);
        if (!cancelled)
          setState({
            recordKey: index[currentUrl] || (sourceUrl && index[sourceUrl]) || null,
            resolving: false,
          });
      } catch (e) {
        console.error("Bluesky record key lookup failed:", e);
        if (!cancelled) setState({ recordKey: null, resolving: false });
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [frontMatterKey, blueSkyConfig?.handle, currentUrl, sourceUrl]);

  return state;
}

interface LikesResponse {
  likes: { actor: BlueskyActor }[];
}

interface RepostedByResponse {
  repostedBy: BlueskyActor[];
}

async function fetchActors(
  endpoint: "getLikes" | "getRepostedBy",
  uri: string,
  listKey: "likes" | "repostedBy",
): Promise<BlueskyActor[]> {
  const url = `https://public.api.bsky.app/xrpc/app.bsky.feed.${endpoint}?uri=${encodeURIComponent(
    uri,
  )}&limit=${MAX_ACTORS_PER_ENDPOINT}`;

  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);

  const data = (await res.json()) as LikesResponse | RepostedByResponse;
  return listKey === "likes"
    ? (data as LikesResponse).likes.map(({ actor }) => actor)
    : (data as RepostedByResponse).repostedBy;
}

interface PostThreadResponse {
  thread: {
    post: { likeCount: number; repostCount: number; uri: string };
    replies?: BlueskyReplyNode[];
  };
}

// Shared by index.tsx (headline copy) and likes.tsx (counts + facepile) so the
// thread/likes/reposts fetches happen once per post, not once per consumer.
export default function useBlueskyEngagement(
  metadata: BlueskyMetadata | undefined,
): EngagementStats {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  const [stats, setStats] = useState<EngagementStats>({
    likes: null,
    reposts: null,
    engaged: [],
    loading: true,
    unavailable: false,
  });

  useEffect(() => {
    if (!blueskyRecordKey || !blueSkyConfig?.handle) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- resets stats when the post/config identity changes; the effect also fetches below, it's not derivable at render time
      setStats({
        likes: null,
        reposts: null,
        engaged: [],
        loading: false,
        unavailable: false,
      });
      return;
    }

    const fetchData = async () => {
      const postUri = `at://${blueSkyConfig.handle}/app.bsky.feed.post/${blueskyRecordKey}`;
      const threadUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(
        postUri,
      )}&depth=5`;

      // Fetch the thread and the moderation list in parallel — neither depends on the other.
      // The list is cached 1 h in sessionStorage, so after the first load it resolves instantly.
      try {
        const [threadRes, listBlocked] = await Promise.all([
          fetch(threadUrl),
          blueSkyConfig.blockedList
            ? fetchBlockedList(blueSkyConfig.blockedList).catch(() => new Set<string>())
            : Promise.resolve(new Set<string>()),
        ]);
        const blockedHandles = new Set([
          ...(blueSkyConfig.blockedHandles ?? []),
          ...listBlocked,
        ]);

        const res = threadRes;
        if (!res.ok) throw new Error("Failed to fetch post data");

        const data = (await res.json()) as PostThreadResponse;
        const { likeCount, repostCount, uri: resolvedUri } = data.thread.post;
        const commenters = flattenReplyAuthors(data.thread.replies);

        setStats({
          likes: likeCount,
          reposts: repostCount,
          engaged: mergeEngagement({ commenters }, blueSkyConfig.handle, blockedHandles),
          loading: false,
          unavailable: false,
        });

        // resolvedUri carries the post's DID (getLikes/getRepostedBy return nothing for a handle-based URI)
        const [likers, reposters] = await Promise.all([
          likeCount > 0 ? fetchActors("getLikes", resolvedUri, "likes") : [],
          repostCount > 0 ? fetchActors("getRepostedBy", resolvedUri, "repostedBy") : [],
        ]);

        setStats((prev) => ({
          ...prev,
          engaged: mergeEngagement(
            { likers, reposters, commenters },
            blueSkyConfig.handle,
            blockedHandles,
          ),
        }));
      } catch (e) {
        // Offline, blocked by the visitor (ad-blocker, DNS filtering), or Bluesky
        // itself down — never surface this as a JS error, just mark the data as
        // unavailable so the UI doesn't claim "no one reacted yet" instead.
        console.error("Error fetching Bluesky stats:", e);
        setStats({
          likes: null,
          reposts: null,
          engaged: [],
          loading: false,
          unavailable: true,
        });
      }
    };

    fetchData();
  }, [
    blueskyRecordKey,
    blueSkyConfig?.handle,
    blueSkyConfig?.blockedHandles,
    blueSkyConfig?.blockedList,
  ]);

  return stats;
}
```

**src/components/Bluesky/post.tsx**

```typescript
import Icon from "./bluesky.svg";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import type { BlueskyMetadata, BlueskySiteConfig } from "./useBlueskyEngagement";

interface Props {
  metadata: BlueskyMetadata;
}

export default function BlueskyPost({ metadata }: Props) {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  if (!blueSkyConfig?.handle || !blueskyRecordKey) {
    return null;
  }

  const postUrl = `https://bsky.app/profile/${blueSkyConfig.handle}/post/${blueskyRecordKey}`;

  return (
    <a
      href={postUrl}
      target="_blank"
      rel="noopener noreferrer"
      className={styles.blueskyButton}
      aria-label={translate({
        id: "bluesky.post.ariaLabel",
        message: "See the post on Bluesky",
      })}
    >
      {/* alt dropped: SVGProps has no such prop — was a no-op DOM attribute even before this
          migration, and the link's own aria-label already names the icon. */}
      <Icon className={styles.blueskyLogo} />
      <Translate id="bluesky.post.label">Like, share or comment on Bluesky</Translate>
    </a>
  );
}
```

**src/components/Bluesky/likes.tsx**

```typescript
import styles from "./styles.module.css";
import { translate } from "@docusaurus/Translate";
import type {
  EngagedPerson,
  EngagementAction,
  EngagementStats,
} from "./useBlueskyEngagement";

const MAX_AVATARS = 10;

// Bluesky facets are keyed by "liked", "reposted", "commented" — a person can carry more than one.
function describeActions(actions: Set<EngagementAction>): string {
  // Each verb is translated on its own and the list is joined here. That holds in French too
  // ("a aimé, a repartagé et a commenté"), which is cheaper than translating all seven
  // combinations as whole sentences.
  const verbs: string[] = [];
  if (actions.has("liked")) {
    verbs.push(translate({ id: "bluesky.likes.verbLiked", message: "liked" }));
  }
  if (actions.has("reposted")) {
    verbs.push(translate({ id: "bluesky.likes.verbReposted", message: "reposted" }));
  }
  if (actions.has("commented")) {
    verbs.push(translate({ id: "bluesky.likes.verbCommented", message: "commented on" }));
  }

  if (verbs.length === 1) return verbs[0];
  const and = translate({ id: "bluesky.likes.and", message: "and" });
  return `${verbs.slice(0, -1).join(", ")} ${and} ${verbs[verbs.length - 1]}`;
}

interface Props {
  stats: EngagementStats;
}

export default function BlueskyLikes({ stats }: Props) {
  if (stats.loading || stats.likes === null) {
    return null;
  }

  const shown: EngagedPerson[] = stats.engaged.slice(0, MAX_AVATARS);
  const extraEngaged = stats.engaged.length - shown.length;

  return (
    <span className={styles.blueskyPostLikes}>
      <span
        className={styles.blueskyCommentLikes}
        title={translate(
          {
            id: "bluesky.likes.likesTitle",
            message: "The original post has {count} likes on Bluesky",
          },
          { count: stats.likes },
        )}
      >
        {stats.likes}
      </span>
      <span
        className={styles.blueskyCommentReposts}
        title={translate(
          {
            id: "bluesky.likes.repostsTitle",
            message: "The original post has been shared {count} times on Bluesky",
          },
          // Only `likes` is narrowed by the guard above; `reposts` can still be null here.
          { count: stats.reposts ?? 0 },
        )}
      >
        {stats.reposts}
      </span>
      {shown.length > 0 && (
        <span className={styles.blueskyLikersAvatars}>
          {shown.map((person, index) => (
            <a
              key={person.did}
              href={`https://bsky.app/profile/${person.handle}`}
              target="_blank"
              rel="noopener noreferrer"
              className={styles.blueskyLikersAvatarLink}
              style={{ zIndex: shown.length - index }}
              title={translate(
                {
                  id: "bluesky.likes.personTitle",
                  message: "{name} (@{handle}) {actions} this post",
                },
                {
                  name: person.displayName,
                  handle: person.handle,
                  actions: describeActions(person.actions),
                },
              )}
            >
              <img
                src={person.avatar}
                alt={translate(
                  { id: "bluesky.avatarAlt", message: "{name}'s avatar" },
                  { name: person.displayName },
                )}
                className={styles.blueskyLikersAvatar}
              />
            </a>
          ))}
          {extraEngaged > 0 && (
            <span className={styles.blueskyLikersAvatarMore}>+{extraEngaged}</span>
          )}
        </span>
      )}
    </span>
  );
}
```

**src/components/Bluesky/comments.tsx**

```typescript
import { useState, useEffect, type ReactNode } from "react";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { fetchBlockedList } from "./useBlueskyEngagement";
import type {
  BlueskyEmbed,
  BlueskyMetadata,
  BlueskyPostRecord,
  BlueskyReplyNode,
  BlueskySiteConfig,
} from "./useBlueskyEngagement";

// Bluesky facet indices are UTF-8 byte offsets, not JS string indices.
// TextEncoder/TextDecoder ensures correct slicing for non-ASCII text (emoji, accents…).
function renderPostText(record: BlueskyPostRecord, isOwner: boolean): ReactNode {
  const text = record.text;
  const facets = record.facets || [];
  if (facets.length === 0) return text;

  const encoder = new TextEncoder();
  const decoder = new TextDecoder();
  const bytes = encoder.encode(text);

  const parts: ReactNode[] = [];
  let lastIndex = 0;

  facets.forEach((facet, idx) => {
    const start = facet.index.byteStart;
    const end = facet.index.byteEnd;

    const before = decoder.decode(bytes.slice(lastIndex, start));
    if (before) parts.push(before);

    const linkFeature = facet.features.find(
      (f) => f.$type === "app.bsky.richtext.facet#link",
    );
    if (linkFeature && isOwner) {
      // Site owner's links are trusted — render as a clickable anchor.
      parts.push(
        <a
          key={`link-${idx}`}
          href={linkFeature.uri}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          {decoder.decode(bytes.slice(start, end))}
        </a>,
      );
    } else {
      // Other authors' links are unvetted (spam risk).
      // Render the anchor text only — the original is reachable via "View comment".
      parts.push(decoder.decode(bytes.slice(start, end)));
    }

    lastIndex = end;
  });

  if (lastIndex < bytes.length) {
    parts.push(decoder.decode(bytes.slice(lastIndex)));
  }

  return parts;
}

function renderEmbed(embed: BlueskyEmbed | null | undefined): ReactNode {
  if (!embed) return null;

  // Narrowed by property presence, not by `$type` equality: `BlueskyEmbed`'s catch-all
  // member types `$type` as a plain `string` (any other embed kind the API can return —
  // video, record quote, …), so a literal-equality check can't exclude it and `embed.external`
  // wouldn't narrow.
  if ("external" in embed) {
    const { uri, title, thumb } = embed.external;

    return (
      <a
        href={uri}
        target="_blank"
        rel="noopener noreferrer nofollow"
        className={styles.blueskyCommentEmbed}
      >
        {thumb && <img src={thumb} alt="" className={styles.blueskyCommentEmbedThumb} />}
        <div className={styles.blueskyCommentEmbedContent}>
          <strong>{title}</strong>
        </div>
      </a>
    );
  }

  if ("images" in embed) {
    return (
      <div className={styles.blueskyCommentImages}>
        {embed.images.map((image, index) => (
          <img
            key={index}
            src={image.fullsize}
            alt={
              image.alt ||
              translate({ id: "bluesky.embeddedImage", message: "Embedded image" })
            }
            className={styles.blueskyCommentImage}
          />
        ))}
      </div>
    );
  }

  return null;
}

interface FlattenedReply extends BlueskyReplyNode {
  depth: number;
}

function BlueskyComment({
  reply,
  ownerHandle,
}: {
  reply: FlattenedReply;
  ownerHandle?: string;
}) {
  const isOwner = !!ownerHandle && reply.post.author.handle === ownerHandle;
  const recordKey = reply.post.uri.split("/").pop();
  const profileUrl = `https://bsky.app/profile/${reply.post.author.handle}`;
  const commentUrl = `https://bsky.app/profile/${reply.post.author.handle}/post/${recordKey}`;
  const date = new Date(reply.post.indexedAt).toLocaleString(undefined, {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });

  return (
    <div
      className={styles.blueskyCommentContainer}
      style={{ paddingLeft: `${1.5 + reply.depth * 1.5}rem` }}
    >
      <div className={`${styles.blueskyCommentHeader} mb-2 flex items-center`}>
        <a href={profileUrl} target="_blank" rel="noopener noreferrer nofollow">
          <img
            src={reply.post.author.avatar}
            alt={translate(
              { id: "bluesky.avatarAlt", message: "{name}'s avatar" },
              { name: reply.post.author.displayName },
            )}
            className={styles.blueskyCommentAvatar}
          />
        </a>
        <div className={styles.blueskyCommentAuthorInfos}>
          <a
            href={profileUrl}
            target="_blank"
            rel="noopener noreferrer nofollow"
            className={styles.blueskyCommentAuthorDisplayName}
          >
            {reply.post.author.displayName}
          </a>
          <span className={styles.blueskyCommentAuthorHandle}>
            @{reply.post.author.handle}
          </span>
        </div>
      </div>

      <span className={styles.blueskyCommentDate}>{date}</span>

      <p className={styles.blueskyCommentText}>
        {renderPostText(reply.post.record, isOwner)}
      </p>

      {renderEmbed(reply.post.embed)}

      <div className={styles.blueskyCommentFooter}>
        <span className={styles.blueskyCommentLikes}>{reply.post.likeCount}</span>
        <span className={styles.blueskyCommentReposts}>
          {reply.post.repostCount || 0}
        </span>
        <a
          className={styles.blueskyCommentLink}
          href={commentUrl}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          <Translate id="bluesky.comments.view">View comment</Translate>
        </a>
      </div>
    </div>
  );
}

interface PostThreadResponse {
  thread: {
    replies?: BlueskyReplyNode[];
  };
}

interface Props {
  metadata: BlueskyMetadata;
}

export default function BlueskyComments({ metadata }: Props) {
  const { siteConfig } = useDocusaurusContext();
  const blueSkyConfig = siteConfig?.customFields?.bluesky as
    BlueskySiteConfig | undefined;
  const blueskyRecordKey = metadata?.frontMatter?.blueskyRecordKey;

  const [comments, setComments] = useState<FlattenedReply[] | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    if (!blueskyRecordKey || !blueSkyConfig?.handle) return;

    const fetchComments = async () => {
      try {
        const postUri = `at://${blueSkyConfig.handle}/app.bsky.feed.post/${blueskyRecordKey}`;
        const url =
          "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=5&uri=" +
          encodeURIComponent(postUri);

        // Fetch thread and moderation list in parallel — shares the sessionStorage cache
        // with useBlueskyEngagement so the second caller resolves instantly.
        const [res, listBlocked] = await Promise.all([
          fetch(url),
          blueSkyConfig?.blockedList
            ? fetchBlockedList(blueSkyConfig.blockedList).catch(() => new Set<string>())
            : Promise.resolve(new Set<string>()),
        ]);
        if (!res.ok) throw new Error("Failed to fetch post thread");
        const data = (await res.json()) as PostThreadResponse;

        const blocked = new Set([
          ...(blueSkyConfig?.blockedHandles ?? []),
          ...listBlocked,
        ]);
        const allComments: FlattenedReply[] = [];
        const flattenReplies = (arr: BlueskyReplyNode[] | undefined, depth: number) => {
          if (!arr) return;
          for (const r of arr) {
            // Skip blocked handles (e.g. spammers hidden on bsky.app but still
            // returned by the public API). Sub-replies are still walked so that
            // legitimate replies to a spam comment remain visible.
            if (!blocked.has(r.post.author.handle)) {
              allComments.push({ ...r, depth });
            }
            if (r.replies) flattenReplies(r.replies, depth + 1);
          }
        };

        if (data.thread?.replies) {
          flattenReplies(data.thread.replies, 0);
        }

        setComments(allComments);
      } catch (err) {
        // Offline, blocked by the visitor, or Bluesky unreachable: stay silent
        // rather than surface a raw error — same outcome as no comments to show.
        console.error(err);
        setError(true);
      }
    };
    fetchComments();
  }, [
    blueskyRecordKey,
    blueSkyConfig?.handle,
    blueSkyConfig?.blockedHandles,
    blueSkyConfig?.blockedList,
  ]);

  if (!blueskyRecordKey || error) return null;
  if (comments === null) {
    return (
      <p>
        <Translate id="bluesky.comments.loading">Loading comments…</Translate>
      </p>
    );
  }

  const postUrl = `https://bsky.app/profile/${blueSkyConfig?.handle}/post/${blueskyRecordKey}`;

  if (comments.length === 0)
    return (
      <p className={styles.blueskyNoCommentYet}>
        <Translate id="bluesky.comments.none">
          This post is waiting for its first comment.
        </Translate>
        &nbsp;
        <a
          className={styles.blueskyNoCommentYetCTA}
          href={postUrl}
          target="_blank"
          rel="noopener noreferrer nofollow"
        >
          <Translate id="bluesky.comments.cta">Share your thoughts!</Translate>
        </a>
      </p>
    );

  return (
    <div className={styles.blueskyCommentsContainer}>
      <h3>
        <Translate id="bluesky.comments.heading" values={{ count: comments.length }}>
          {"💬 Comments from Bluesky ({count})"}
        </Translate>
      </h3>
      {comments.map((reply) => (
        <BlueskyComment
          key={reply.post.uri}
          reply={reply}
          ownerHandle={blueSkyConfig?.handle}
        />
      ))}
    </div>
  );
}
```

**src/components/Bluesky/styles.module.css**

```css
.blueskyLogo {
  width: 20px;
  height: 20px;
}

.blueskyContainer {
  margin: 2rem 0 0.5rem;
  padding: 1.1rem 1.25rem 1.4rem;
  border: 1px solid var(--ifm-color-emphasis-200);
  border-radius: 8px;
  background: var(--ifm-background-surface-color);
}

.blueskyHeadline {
  display: block;
  font-weight: 500;
  color: var(--ifm-color-content);
  margin-bottom: 0.75rem;
}

.blueskyAccountNote {
  display: block;
  margin-top: 0.6rem;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyCommentsContainer {
  border: 1px solid var(--ifm-color-emphasis-200);
  margin-top: 1rem;
  padding: 0.4rem 0.9rem;
  margin-bottom: 1.25rem;
}

.blueskyCommentContainer {
  margin-bottom: 1rem;
  background-color: var(--ifm-color-emphasis-100);
  padding: 0.8rem;
  border-radius: 6px;
  border-left: 2px solid var(--ifm-color-emphasis-300);
  color: var(--ifm-font-color-base);
}

.blueskyCommentHeader {
  display: flex;
  align-items: center;
  gap: 10px;
}

.blueskyCommentAuthorInfos {
  display: flex;
  flex-direction: column;
}

.blueskyCommentAvatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  margin-right: 0.5rem;
}

.blueskyCommentAuthorDisplayName {
  margin: 0;
  font-weight: bold;
}

.blueskyCommentAuthorHandle {
  margin: 0;
  font-size: smaller;
  font-weight: normal;
  color: var(--ifm-color-emphasis-600);
  display: block;
}

.blueskyCommentDate {
  margin-left: auto;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyCommentText {
  margin: 0.3rem 0 0;
}

.blueskyCommentLikes::before {
  content: "❤️\a0";
}

.blueskyCommentLikes::after {
  content: " ";
}

.blueskyCommentReposts::before {
  content: "🔁\a0";
}

.blueskyCommentReposts::after {
  content: " ";
}

.blueskyCommentLink::before {
  content: "→\a0";
}

.blueskyCommentLink::after {
  content: " ";
}

.blueskyCommentLink {
  margin-right: 0.25rem;
  display: inline-flex;
  align-items: center;
  color: var(--ifm-color-emphasis-600);
  text-decoration: none;
  margin-top: 0.25rem;
}

img.blueskyCommentEmbedThumb {
  max-height: 250px;
}

.blueskyCommentImages {
  margin-top: 0.5rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.blueskyCommentImage {
  max-width: 100%;
  height: auto;
  border-radius: 0.25rem;
  border: 1px solid var(--ifm-color-emphasis-300);
}

.blueskyNoCommentYet {
  color: var(--ifm-color-emphasis-600);
  font-size: 1rem;
  line-height: 1.5;
  text-align: center;
  padding: 20px;
  background-color: var(--ifm-color-emphasis-100);
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 8px;
  margin: 20px 0;
}

.blueskyNoCommentYetCTA {
  font-weight: bold;
  color: var(--ifm-color-primary-dark);
  cursor: pointer;
  text-decoration: underline;
}

[data-theme="dark"] .blueskyNoCommentYetCTA {
  color: var(--ifm-color-primary-light);
}

.blueskyPostLikes {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.blueskyLikersAvatars {
  display: inline-flex;
  align-items: center;
  margin-left: 0.4rem;
}

.blueskyLikersAvatarLink {
  margin-left: -1rem;
  line-height: 0;
}

.blueskyLikersAvatarLink:first-child {
  margin-left: 0;
}

.blueskyLikersAvatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid var(--ifm-background-color);
  display: block;
}

.blueskyLikersAvatarMore {
  margin-left: 0.4rem;
  font-size: smaller;
  color: var(--ifm-color-emphasis-600);
}

.blueskyEngagementRow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
}

.blueskyButton {
  align-items: center;
  background-color: var(--ifm-color-emphasis-200);
  border-radius: 6px;
  border: 1px solid var(--ifm-color-emphasis-300);
  box-shadow: none;
  color: var(--ifm-font-color-base);
  display: inline-flex;
  font-size: 0.9rem;
  font-weight: 400;
  gap: 0.4rem;
  margin-bottom: 0.8rem;
  margin-right: 0.4rem;
  padding: 0.4rem 0.9rem;
  text-decoration: none;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}

.blueskyButton:hover {
  background-color: var(--ifm-color-primary);
  border-color: var(--ifm-color-primary);
  color: white;
}
```
