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.
Traduction automatique
Journal des modifications

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, 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.
Ce que vous allez obtenir
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 :



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 :
- Vous publiez votre article sur votre blog,
- Vous partagez l'article sur Bluesky (en utilisant la fonctionnalité « Partager l'article », bien sûr),
- 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), - 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: xxxxoùxxxxest votre Bluesky Record key. - 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 :
- 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,
- Afficher le nombre de likes et de reposts sous votre article et,
- 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 Créer notre propre composant React Docusaurus et proposer un bouton « Partager sur Bluesky »), 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
Voici la nouvelle structure Bluesky de notre composant :
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> );}This component displays a Bluesky engagement section for a blog article. It fetches data about whether the article has been posted to Bluesky and how many people have engaged with it, then shows either a 'share this article' prompt or an engagement counter with likes and comments. The component carefully handles loading and error states to avoid showing misleading messages to the user.
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 :
- Un bouton de partage,
- Un bouton Post (pour sauter vers votre post sur Bluesky),
- Une zone likes (pour afficher le nombre de likes et de reposts de votre post sur Bluesky) et
- Une zone pour afficher les commentaires reçus sur Bluesky
2/6 Le hook partagé de récupération des données
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.
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;}This file provides utilities for integrating Bluesky social media engagement into a Docusaurus blog. It defines TypeScript interfaces for Bluesky API responses and blog metadata, exports functions to fetch blocked user lists and engagement metrics (likes, reposts, replies), and provides React hooks that resolve which Bluesky post corresponds to a blog article and fetch engagement statistics for display.
3/6 Notre bouton « Like, share or comment »
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> );}This React component renders a link to a Bluesky post. It pulls the user's Bluesky handle from the site configuration and the post identifier from the page metadata, then builds a URL and displays it as a clickable button with an icon and text. If either the handle or post ID is missing, it renders nothing instead.
4/6 Notre composant pour obtenir le nombre de likes et de reposts
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> );}This component displays engagement metrics from a Bluesky post—like count, repost count, and a grid of avatars from people who engaged with it. It caps the avatar display at 10 people and shows a '+N' indicator if there are more; it also translates all text for internationalization and builds readable descriptions of what each person did (liked, reposted, commented, or combinations thereof).
Remarque
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
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> <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> );}This component fetches and displays comments from Bluesky (a social media platform) on a blog post. It handles rendering the comment thread with proper text formatting for links, embeds like images and external links, and filters out blocked users while preserving replies to those users. The component also manages loading states and gracefully handles errors.
6/6 Notre nouveau 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;}This stylesheet styles a Bluesky social media embed component with various sections for displaying posts, comments, user avatars, and engagement metrics. It uses CSS variables for theming to support both light and dark modes, and includes hover effects for interactive elements like buttons and links.
Mettre à jour le fichier de configuration Docusaurus
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 :
// 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", }, }
}Ce snippet montre une structure de configuration partielle où tu dois ajouter tes identifiants personnels pour les services externes. Ici, on voit comment configurer ton handle Bluesky dans l'objet customFields pour que l'application puisse l'utiliser.
Danger
Évidemment, utilisez le vôtre ; pas le mien ;-)
Tous les fichiers d'un coup d'œil
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 :
Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.
Chargement des commentaires…
