diff --git a/src/components/content/ForumActivityItem.astro b/src/components/content/ForumActivityItem.astro
new file mode 100644
index 0000000..1af5866
--- /dev/null
+++ b/src/components/content/ForumActivityItem.astro
@@ -0,0 +1,158 @@
+---
+import Avatar, { type AvatarSource } from "../Avatar.astro";
+import type { DiscourseActivityTopic } from "../../lib/discourse-activity";
+import { createAvatarFallback } from "../../lib/avatar-fallback";
+
+type Props = DiscourseActivityTopic;
+
+const { title, url, lastActiveAt, replyCount, author, tags } = Astro.props;
+const authorLabel = author.displayName || author.username;
+
+const avatar: AvatarSource = author.avatarUrl
+ ? { url: author.avatarUrl }
+ : createAvatarFallback({
+ label: authorLabel,
+ colorKey: author.username,
+ emptyInitials: "?",
+ });
+const primaryTag = tags[0];
+const replyLabel = `${replyCount} ${replyCount === 1 ? "reply" : "replies"}`;
+---
+
+
+
+
+
+ {title}
+
+ {authorLabel}
+ ·
+
+ ·
+ {replyLabel}
+ {primaryTag && (
+ <>
+ ·
+ #{primaryTag}
+ >
+ )}
+
+
+ ↗
+
+
+
+
diff --git a/src/components/content/PostCard.astro b/src/components/content/PostCard.astro
index 19f7242..a455c9d 100644
--- a/src/components/content/PostCard.astro
+++ b/src/components/content/PostCard.astro
@@ -1,6 +1,7 @@
---
import Avatar, { type AvatarSource } from "../Avatar.astro";
import SafeExternalLink from "../SafeExternalLink.astro";
+import { hueFromString } from "../../lib/avatar-fallback";
interface Props {
title: string;
@@ -37,13 +38,6 @@ const shouldShowSharedBy = !!sharedBy && sharedBy.did !== author.did;
const tag = tags?.[0];
const community = `@${source}`;
-function hueFromString(input: string): number {
- let h = 0;
- for (let i = 0; i < input.length; i++) {
- h = (h * 31 + input.charCodeAt(i)) | 0;
- }
- return ((h % 360) + 360) % 360;
-}
const communityHue = hueFromString(source);
---
diff --git a/src/lib/avatar-fallback.ts b/src/lib/avatar-fallback.ts
new file mode 100644
index 0000000..7359318
--- /dev/null
+++ b/src/lib/avatar-fallback.ts
@@ -0,0 +1,37 @@
+export interface AvatarFallback {
+ initials: string;
+ color: string;
+}
+
+export function hueFromString(input: string): number {
+ let hash = 0;
+ for (const character of input) {
+ hash = ((hash << 5) - hash + character.charCodeAt(0)) | 0;
+ }
+ return ((hash % 360) + 360) % 360;
+}
+
+function initialsFromLabel(label: string): string {
+ const segments = label.replace(/^@/, "").split(/[.\-_ ]/);
+ const meaningfulSegments = segments.filter(Boolean);
+ const initialSegments = meaningfulSegments.slice(0, 2);
+ const initials = initialSegments.map(
+ (segment) => segment[0]?.toUpperCase() ?? "",
+ );
+ return initials.join("");
+}
+
+export function createAvatarFallback({
+ label,
+ colorKey = label,
+ emptyInitials = "",
+}: {
+ label: string;
+ colorKey?: string;
+ emptyInitials?: string;
+}): AvatarFallback {
+ return {
+ initials: initialsFromLabel(label) || emptyInitials,
+ color: `oklch(55% 0.12 ${hueFromString(colorKey)})`,
+ };
+}
diff --git a/src/lib/discourse-activity.test.ts b/src/lib/discourse-activity.test.ts
new file mode 100644
index 0000000..68f6529
--- /dev/null
+++ b/src/lib/discourse-activity.test.ts
@@ -0,0 +1,453 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ createDiscourseActivityLoader,
+ DiscourseActivityError,
+ fetchDiscourseActivity,
+} from "./discourse-activity.js";
+
+function topic({
+ id,
+ lastPostedDay,
+ bumpedDay = lastPostedDay,
+ userId = 42,
+}: {
+ id: number;
+ lastPostedDay: number;
+ bumpedDay?: number;
+ userId?: number;
+}) {
+ return {
+ id,
+ title: `Topic ${id}`,
+ slug: `topic-${id}`,
+ posts_count: id + 1,
+ reply_count: id,
+ created_at: "2026-07-01T12:00:00.000Z",
+ last_posted_at: `2026-07-${String(lastPostedDay).padStart(2, "0")}T12:00:00.000Z`,
+ bumped_at: `2026-07-${String(bumpedDay).padStart(2, "0")}T12:00:00.000Z`,
+ category_id: 4,
+ tags: ["working-groups"],
+ posters: [
+ {
+ description: "Original Poster",
+ user_id: userId,
+ },
+ ],
+ };
+}
+
+test("fetchDiscourseActivity returns the ten most recently active topics", async () => {
+ const requested: string[] = [];
+ const response = {
+ users: [
+ {
+ id: 42,
+ username: "member.example",
+ name: "Member Example",
+ avatar_template: "/user_avatar/member/{size}/1.png",
+ },
+ ],
+ topic_list: {
+ more_topics_url: "/latest?page=1",
+ per_page: 20,
+ topics: Array.from({ length: 12 }, (_, index) =>
+ topic({ id: index + 1, lastPostedDay: index + 1 }),
+ ),
+ },
+ };
+
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ limit: 10,
+ fetch: async (input) => {
+ requested.push(input.toString());
+ return Response.json(response);
+ },
+ });
+
+ assert.deepEqual(
+ page.topics.map((entry) => entry.id),
+ ["12", "11", "10", "9", "8", "7", "6", "5", "4", "3"],
+ );
+ assert.equal(page.topics[0]?.url, "https://forum.example/t/topic-12/12");
+ assert.equal(page.topics[0]?.author.displayName, "Member Example");
+ assert.equal(page.topics[0]?.author.avatarUrl, "https://forum.example/user_avatar/member/96/1.png");
+ assert.deepEqual(page.pagination, {
+ nextPageUrl: "https://forum.example/latest?page=1",
+ perPage: 20,
+ });
+ assert.equal(requested.length, 1);
+ assert.match(requested[0] ?? "", /\/latest\.json\?/);
+ assert.match(requested[0] ?? "", /per_page=20/);
+});
+
+test("fetchDiscourseActivity orders and timestamps topics by bumped activity", async () => {
+ const { bumped_at: _, ...topicWithoutBumpedAt } = topic({
+ id: 3,
+ lastPostedDay: 7,
+ });
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [],
+ topic_list: {
+ topics: [
+ topic({ id: 1, lastPostedDay: 20, bumpedDay: 5 }),
+ topic({ id: 2, lastPostedDay: 10, bumpedDay: 9 }),
+ topicWithoutBumpedAt,
+ ],
+ },
+ }),
+ });
+
+ assert.deepEqual(page.topics.map((entry) => entry.id), ["2", "3", "1"]);
+ assert.equal(
+ page.topics[0]?.lastActiveAt.toISOString(),
+ "2026-07-09T12:00:00.000Z",
+ );
+});
+
+test("fetchDiscourseActivity keeps static avatar paths", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [
+ {
+ id: -1,
+ username: "system",
+ avatar_template: "/uploads/default/original/avatar.png",
+ },
+ ],
+ topic_list: {
+ topics: [topic({ id: 1, lastPostedDay: 1, userId: -1 })],
+ },
+ }),
+ });
+
+ assert.equal(
+ page.topics[0]?.author.avatarUrl,
+ "https://forum.example/uploads/default/original/avatar.png",
+ );
+});
+
+test("fetchDiscourseActivity resolves absolute avatar templates", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [
+ {
+ id: 42,
+ username: "external-avatar",
+ avatar_template:
+ "https://avatars.discourse-cdn.com/v4/letter/e/{size}/1.png",
+ },
+ ],
+ topic_list: {
+ topics: [topic({ id: 1, lastPostedDay: 1 })],
+ },
+ }),
+ });
+
+ assert.equal(
+ page.topics[0]?.author.avatarUrl,
+ "https://avatars.discourse-cdn.com/v4/letter/e/96/1.png",
+ );
+});
+
+test("fetchDiscourseActivity rejects non-web avatar schemes", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [
+ {
+ id: 42,
+ username: "invalid-avatar",
+ avatar_template: "javascript:alert(1)",
+ },
+ ],
+ topic_list: {
+ topics: [topic({ id: 1, lastPostedDay: 1 })],
+ },
+ }),
+ });
+
+ assert.equal(page.topics[0]?.author.avatarUrl, undefined);
+});
+
+test("fetchDiscourseActivity keeps users with empty optional fields", async () => {
+ const rawTopic = {
+ ...topic({ id: 1, lastPostedDay: 1 }),
+ excerpt: "",
+ };
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [
+ {
+ id: 42,
+ username: "member.example",
+ name: "",
+ avatar_template: "",
+ },
+ ],
+ topic_list: { topics: [rawTopic] },
+ }),
+ });
+
+ assert.equal(page.topics[0]?.author.username, "member.example");
+ assert.equal(page.topics[0]?.author.displayName, undefined);
+ assert.equal(page.topics[0]?.author.avatarUrl, undefined);
+ assert.equal(page.topics[0]?.excerpt, undefined);
+});
+
+test("fetchDiscourseActivity falls back from malformed optional dates", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [],
+ topic_list: {
+ topics: [
+ {
+ ...topic({ id: 8, lastPostedDay: 8 }),
+ bumped_at: "not-a-date",
+ },
+ ],
+ },
+ }),
+ });
+
+ assert.equal(
+ page.topics[0]?.lastActiveAt.toISOString(),
+ "2026-07-08T12:00:00.000Z",
+ );
+});
+
+test("fetchDiscourseActivity keeps valid optional array entries", async () => {
+ const rawTopic = topic({ id: 1, lastPostedDay: 1 });
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [
+ {
+ id: 42,
+ username: "member.example",
+ },
+ ],
+ topic_list: {
+ topics: [
+ {
+ ...rawTopic,
+ tags: ["working-groups", 42],
+ posters: [
+ { description: 42, user_id: "invalid" },
+ ...rawTopic.posters,
+ ],
+ },
+ ],
+ },
+ }),
+ });
+
+ assert.deepEqual(page.topics[0]?.tags, ["working-groups"]);
+ assert.equal(page.topics[0]?.author.username, "member.example");
+});
+
+test("fetchDiscourseActivity keeps poster identity when description is malformed", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [{ id: 42, username: "member.example" }],
+ topic_list: {
+ topics: [
+ {
+ ...topic({ id: 1, lastPostedDay: 1 }),
+ posters: [{ description: 42, user_id: 42 }],
+ },
+ ],
+ },
+ }),
+ });
+
+ assert.equal(page.topics[0]?.author.username, "member.example");
+});
+
+test("fetchDiscourseActivity gives topics independent tag arrays", async () => {
+ const firstWithoutTags = {
+ ...topic({ id: 1, lastPostedDay: 1 }),
+ tags: undefined,
+ };
+ const secondWithoutTags = {
+ ...topic({ id: 2, lastPostedDay: 2 }),
+ tags: undefined,
+ };
+
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [],
+ topic_list: { topics: [firstWithoutTags, secondWithoutTags] },
+ }),
+ });
+
+ page.topics[0]?.tags.push("local-change");
+ assert.deepEqual(page.topics[1]?.tags, []);
+});
+
+test("fetchDiscourseActivity selects category and tag endpoints", async (t) => {
+ const emptyResponse = {
+ users: [],
+ topic_list: { topics: [] },
+ };
+
+ await t.test("category", async () => {
+ let requested = "";
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ source: { type: "category", slug: "working-groups", id: 12 },
+ fetch: async (input) => {
+ requested = input.toString();
+ return Response.json(emptyResponse);
+ },
+ });
+ assert.deepEqual(page.pagination, {
+ nextPageUrl: undefined,
+ perPage: undefined,
+ });
+ assert.equal(
+ new URL(requested).pathname,
+ "/c/working-groups/12/l/latest.json",
+ );
+ });
+
+ await t.test("tag", async () => {
+ let requested = "";
+ await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ source: { type: "tag", tag: "groups & governance" },
+ fetch: async (input) => {
+ requested = input.toString();
+ return Response.json(emptyResponse);
+ },
+ });
+ assert.equal(
+ new URL(requested).pathname,
+ "/tag/groups%20%26%20governance.json",
+ );
+ });
+});
+
+test("fetchDiscourseActivity rejects non-web base URLs before requesting", async () => {
+ let requested = false;
+
+ await assert.rejects(
+ fetchDiscourseActivity({
+ baseUrl: "ftp://forum.example",
+ fetch: async () => {
+ requested = true;
+ return Response.json({ topic_list: { topics: [] } });
+ },
+ }),
+ Error,
+ );
+ assert.equal(requested, false);
+});
+
+test("createDiscourseActivityLoader reuses a bounded result and loads by topic id", async () => {
+ let requestCount = 0;
+ const loader = createDiscourseActivityLoader({
+ baseUrl: "https://forum.example",
+ fetch: async () => {
+ requestCount += 1;
+ return Response.json({
+ users: [],
+ topic_list: { topics: [topic({ id: 7, lastPostedDay: 7 })] },
+ });
+ },
+ });
+
+ const first = await loader.loadCollection({ collection: "forum" });
+ const second = await loader.loadCollection({ collection: "forum" });
+ const entry = await loader.loadEntry({
+ collection: "forum",
+ filter: { id: "7" },
+ });
+
+ assert.equal("error" in first, false);
+ assert.equal("error" in second, false);
+ assert.equal("error" in entry!, false);
+ if ("entries" in first) assert.equal(first.entries[0]?.id, "7");
+ if (entry && "data" in entry) assert.equal(entry.data.title, "Topic 7");
+ assert.equal(requestCount, 1);
+});
+
+test("fetchDiscourseActivity reports unsuccessful and malformed responses", async (t) => {
+ await t.test("unsuccessful response", async () => {
+ await assert.rejects(
+ fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () => new Response(null, { status: 429 }),
+ }),
+ (error: unknown) =>
+ error instanceof DiscourseActivityError &&
+ error.code === "request-failed" &&
+ error.status === 429,
+ );
+ });
+
+ await t.test("skips a malformed topic without hiding valid topics", async () => {
+ const page = await fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () =>
+ Response.json({
+ users: [],
+ topic_list: {
+ topics: [
+ topic({ id: 7, lastPostedDay: 7 }),
+ {
+ ...topic({ id: 8, lastPostedDay: 8 }),
+ created_at: "not-a-date",
+ },
+ ],
+ },
+ }),
+ });
+
+ assert.deepEqual(page.topics.map((entry) => entry.id), ["7"]);
+ });
+
+ await t.test("malformed response", async () => {
+ await assert.rejects(
+ fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () => Response.json({ topics: [] }),
+ }),
+ (error: unknown) =>
+ error instanceof DiscourseActivityError &&
+ error.code === "invalid-response",
+ );
+ });
+
+ await t.test("non-JSON response", async () => {
+ await assert.rejects(
+ fetchDiscourseActivity({
+ baseUrl: "https://forum.example",
+ fetch: async () => new Response("maintenance"),
+ }),
+ (error: unknown) =>
+ error instanceof DiscourseActivityError &&
+ error.code === "invalid-response" &&
+ error.cause instanceof SyntaxError,
+ );
+ });
+});
diff --git a/src/lib/discourse-activity.ts b/src/lib/discourse-activity.ts
new file mode 100644
index 0000000..380722c
--- /dev/null
+++ b/src/lib/discourse-activity.ts
@@ -0,0 +1,396 @@
+import type { LiveLoader } from "astro/loaders";
+import { z } from "astro/zod";
+
+const webUrlSchema = z.url().refine((value) => {
+ const protocol = new URL(value).protocol;
+ return protocol === "http:" || protocol === "https:";
+}, "Expected an HTTP(S) URL");
+
+export const discourseActivityTopicSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ url: webUrlSchema,
+ excerpt: z.string().optional(),
+ createdAt: z.date(),
+ lastActiveAt: z.date(),
+ postCount: z.number().int().nonnegative(),
+ replyCount: z.number().int().nonnegative(),
+ categoryId: z.number().int(),
+ tags: z.array(z.string()),
+ author: z.object({
+ username: z.string(),
+ displayName: z.string().optional(),
+ avatarUrl: webUrlSchema.optional(),
+ }),
+});
+
+export type DiscourseActivityTopic = z.infer<
+ typeof discourseActivityTopicSchema
+>;
+
+export type DiscourseActivityPage = {
+ topics: DiscourseActivityTopic[];
+ pagination: {
+ nextPageUrl?: string;
+ perPage?: number;
+ };
+};
+
+type DiscourseActivitySource =
+ | { type: "latest" }
+ | { type: "category"; slug: string; id: number }
+ | { type: "tag"; tag: string };
+
+interface FetchDiscourseActivityOptions {
+ baseUrl: string;
+ source?: DiscourseActivitySource;
+ limit?: number;
+ fetch?: typeof globalThis.fetch;
+}
+
+interface DiscourseActivityLoaderOptions
+ extends FetchDiscourseActivityOptions {
+ cacheTtlMs?: number;
+ now?: () => number;
+}
+
+export class DiscourseActivityError extends Error {
+ readonly code: "request-failed" | "invalid-response";
+ readonly status?: number;
+
+ constructor({
+ message,
+ code,
+ status,
+ cause,
+ }: {
+ message: string;
+ code: "request-failed" | "invalid-response";
+ status?: number;
+ cause?: unknown;
+ }) {
+ super(message, { cause });
+ this.name = "DiscourseActivityError";
+ this.code = code;
+ this.status = status;
+ }
+}
+
+const discourseDateSchema = z.string().refine(
+ (value) => !Number.isNaN(new Date(value).getTime()),
+ "Expected a valid date",
+).transform((value) => new Date(value));
+
+const optionalNonEmptyStringSchema = z
+ .string()
+ .min(1)
+ .optional()
+ .catch(undefined);
+const optionalDiscourseDateSchema = discourseDateSchema
+ .optional()
+ .catch(undefined);
+
+const discourseUserDecorationSchema = z.object({
+ name: optionalNonEmptyStringSchema,
+ avatar_template: optionalNonEmptyStringSchema,
+});
+
+const discourseTopicDecorationSchema = z.object({
+ excerpt: optionalNonEmptyStringSchema,
+ bumped_at: optionalDiscourseDateSchema,
+ last_posted_at: optionalDiscourseDateSchema,
+});
+
+const discoursePaginationSchema = z.object({
+ more_topics_url: optionalNonEmptyStringSchema,
+ per_page: z.number().int().positive().optional().catch(undefined),
+});
+
+const discoursePosterSchema = z.object({
+ user_id: z.number().int(),
+});
+
+const discourseUserSchema = z.object({
+ id: z.number().int(),
+ username: z.string().min(1),
+ // Decoration is parsed separately so it cannot reject user identity.
+ name: z.unknown(),
+ avatar_template: z.unknown(),
+});
+
+const discourseTopicSchema = z.object({
+ id: z.number().int(),
+ title: z.string().min(1),
+ slug: z.string().min(1),
+ created_at: discourseDateSchema,
+ posts_count: z.number().int().nonnegative(),
+ reply_count: z.number().int().nonnegative(),
+ category_id: z.number().int(),
+ // Decoration is parsed separately so it cannot reject the core topic.
+ excerpt: z.unknown(),
+ bumped_at: z.unknown(),
+ last_posted_at: z.unknown(),
+ tags: z.unknown(),
+ posters: z.unknown(),
+});
+
+const discourseResponseSchema = z.object({
+ // Optional decoration and pagination are normalized independently.
+ users: z.unknown(),
+ topic_list: z.object({
+ topics: z.array(z.unknown()),
+ more_topics_url: z.unknown(),
+ per_page: z.unknown(),
+ }),
+});
+
+type DiscourseTopic = z.infer;
+type DiscourseUser = z.infer;
+
+function extractValidItems(value: unknown, schema: z.ZodType): T[] {
+ const arrayResult = z.array(z.unknown()).safeParse(value);
+ if (!arrayResult.success) return [];
+
+ const validItems: T[] = [];
+ for (const rawItem of arrayResult.data) {
+ const itemResult = schema.safeParse(rawItem);
+ if (itemResult.success) validItems.push(itemResult.data);
+ }
+ return validItems;
+}
+
+function normalizeAuthor({
+ topic,
+ users,
+ baseUrl,
+}: {
+ topic: DiscourseTopic;
+ users: Map;
+ baseUrl: URL;
+}): DiscourseActivityTopic["author"] {
+ const firstPoster = extractValidItems(
+ topic.posters,
+ discoursePosterSchema,
+ )[0];
+ const user = firstPoster ? users.get(firstPoster.user_id) : undefined;
+ const decoration = discourseUserDecorationSchema.parse(user ?? {});
+ const avatarHref = decoration.avatar_template?.replaceAll("{size}", "96");
+ const avatarUrlResult =
+ avatarHref && URL.canParse(avatarHref, baseUrl)
+ ? webUrlSchema.safeParse(new URL(avatarHref, baseUrl).toString())
+ : undefined;
+
+ return {
+ username: user?.username ?? "unknown",
+ displayName: decoration.name,
+ avatarUrl: avatarUrlResult?.success ? avatarUrlResult.data : undefined,
+ };
+}
+
+function normalizeTopic({
+ topic,
+ users,
+ baseUrl,
+}: {
+ topic: DiscourseTopic;
+ users: Map;
+ baseUrl: URL;
+}): DiscourseActivityTopic {
+ const decoration = discourseTopicDecorationSchema.parse(topic);
+
+ return {
+ id: String(topic.id),
+ title: topic.title,
+ url: new URL(
+ `/t/${encodeURIComponent(topic.slug)}/${topic.id}`,
+ baseUrl,
+ ).toString(),
+ excerpt: decoration.excerpt,
+ createdAt: topic.created_at,
+ lastActiveAt:
+ decoration.bumped_at ?? decoration.last_posted_at ?? topic.created_at,
+ postCount: topic.posts_count,
+ replyCount: topic.reply_count,
+ categoryId: topic.category_id,
+ tags: extractValidItems(topic.tags, z.string()),
+ author: normalizeAuthor({ topic, users, baseUrl }),
+ };
+}
+
+function sourcePath(source: DiscourseActivitySource): string {
+ switch (source.type) {
+ case "latest":
+ return "/latest.json";
+ case "category":
+ return `/c/${encodeURIComponent(source.slug)}/${source.id}/l/latest.json`;
+ case "tag":
+ return `/tag/${encodeURIComponent(source.tag)}.json`;
+ default:
+ throw new TypeError("Unsupported Discourse activity source");
+ }
+}
+
+function buildActivityUrl({
+ baseUrl,
+ source,
+ limit,
+}: {
+ baseUrl: URL;
+ source: DiscourseActivitySource;
+ limit: number;
+}): URL {
+ const url = new URL(sourcePath(source), baseUrl);
+ url.searchParams.set("order", "activity");
+ url.searchParams.set("per_page", String(Math.min(100, limit * 2)));
+ return url;
+}
+
+function normalizeDiscoursePage({
+ payload,
+ baseUrl,
+ limit,
+}: {
+ payload: unknown;
+ baseUrl: URL;
+ limit: number;
+}): DiscourseActivityPage {
+ const responseResult = discourseResponseSchema.safeParse(payload);
+ if (!responseResult.success) {
+ throw new DiscourseActivityError({
+ message: "Discourse activity response is malformed",
+ code: "invalid-response",
+ cause: responseResult.error,
+ });
+ }
+
+ const response = responseResult.data;
+ const users = new Map(
+ extractValidItems(response.users, discourseUserSchema).map((user) => [
+ user.id,
+ user,
+ ]),
+ );
+ const topics = response.topic_list.topics
+ .flatMap((rawTopic): DiscourseActivityTopic[] => {
+ const result = discourseTopicSchema.safeParse(rawTopic);
+ return result.success
+ ? [normalizeTopic({ topic: result.data, users, baseUrl })]
+ : [];
+ })
+ .sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime())
+ .slice(0, limit);
+ const pagination = discoursePaginationSchema.parse(response.topic_list);
+ const nextPageUrlResult =
+ pagination.more_topics_url &&
+ URL.canParse(pagination.more_topics_url, baseUrl)
+ ? webUrlSchema.safeParse(
+ new URL(pagination.more_topics_url, baseUrl).toString(),
+ )
+ : undefined;
+
+ return {
+ topics,
+ pagination: {
+ nextPageUrl: nextPageUrlResult?.success
+ ? nextPageUrlResult.data
+ : undefined,
+ perPage: pagination.per_page,
+ },
+ };
+}
+
+export async function fetchDiscourseActivity(
+ options: FetchDiscourseActivityOptions,
+): Promise {
+ const baseUrl = new URL(webUrlSchema.parse(options.baseUrl));
+ const limit = Math.max(1, Math.min(50, options.limit ?? 10));
+ const url = buildActivityUrl({
+ baseUrl,
+ source: options.source ?? { type: "latest" },
+ limit,
+ });
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+ const response = await fetchImpl(url, {
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new DiscourseActivityError({
+ message: `Discourse activity request failed with ${response.status}`,
+ code: "request-failed",
+ status: response.status,
+ });
+ }
+
+ let payload: unknown;
+ try {
+ payload = await response.json();
+ } catch (cause) {
+ throw new DiscourseActivityError({
+ message: "Discourse activity response is malformed",
+ code: "invalid-response",
+ cause,
+ });
+ }
+
+ return normalizeDiscoursePage({
+ payload,
+ baseUrl,
+ limit,
+ });
+}
+
+export function createDiscourseActivityLoader(
+ options: DiscourseActivityLoaderOptions,
+): LiveLoader, { id: string }> {
+ const cacheTtlMs = Math.max(0, options.cacheTtlMs ?? 1000 * 60 * 5);
+ const now = options.now ?? Date.now;
+ let cached:
+ | { page: DiscourseActivityPage; expiresAt: number }
+ | undefined;
+ let pending: Promise | undefined;
+
+ const load = async (): Promise => {
+ if (cached && cached.expiresAt > now()) return cached.page;
+ if (pending) return pending;
+
+ pending = fetchDiscourseActivity(options)
+ .then((page) => {
+ cached = { page, expiresAt: now() + cacheTtlMs };
+ return page;
+ })
+ .finally(() => {
+ pending = undefined;
+ });
+ return pending;
+ };
+
+ return {
+ name: "discourse-activity",
+ async loadCollection() {
+ try {
+ const page = await load();
+ return {
+ entries: page.topics.map((topic) => ({
+ id: topic.id,
+ data: { ...topic },
+ })),
+ };
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error : new Error(String(error)),
+ };
+ }
+ },
+ async loadEntry({ filter }) {
+ try {
+ const page = await load();
+ const topic = page.topics.find((entry) => entry.id === filter.id);
+ return topic ? { id: topic.id, data: { ...topic } } : undefined;
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error : new Error(String(error)),
+ };
+ }
+ },
+ };
+}
diff --git a/src/live.config.ts b/src/live.config.ts
index 8e06352..912423f 100644
--- a/src/live.config.ts
+++ b/src/live.config.ts
@@ -16,6 +16,14 @@ import {
transformEventGroup,
type LoaderArgs,
} from "./lib/live-handlers";
+import {
+ createDiscourseActivityLoader,
+ discourseActivityTopicSchema,
+} from "./lib/discourse-activity";
+import {
+ createAvatarFallback,
+ hueFromString,
+} from "./lib/avatar-fallback";
interface CommunityDefinition {
name: string;
@@ -56,30 +64,9 @@ function avatarInitials(handle: string): string {
.toUpperCase();
}
function avatarColor(handle: string): string {
- let hash = 0;
- for (const ch of handle) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
- const hue = ((hash % 360) + 360) % 360;
- return `oklch(50% 0.13 ${hue})`;
+ return `oklch(50% 0.13 ${hueFromString(handle)})`;
}
-// Per-author avatar fallback derivation for the feed: takes a label that may be
-// either a displayName ("Jane Doe" → "JD") or a handle ("@nyc.atproto.camp" → "NA").
-// Splits on the separators commonly found in handles so the initials track the
-// distinctive parts of the label.
-function authorInitials(label: string): string {
- return label
- .replace(/^@/, "")
- .split(/[.\-_ ]/)
- .slice(0, 2)
- .map((s) => s[0]?.toUpperCase() ?? "")
- .join("");
-}
-function authorColor(name: string): string {
- let hash = 0;
- for (const ch of name) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
- const hue = ((hash % 360) + 360) % 360;
- return `oklch(55% 0.12 ${hue})`;
-}
function enrichAuthor(author: {
did: string;
handle: string;
@@ -93,7 +80,7 @@ function enrichAuthor(author: {
displayName: author.displayName,
avatar: author.avatar
? { url: author.avatar }
- : { initials: authorInitials(label), color: authorColor(label) },
+ : createAvatarFallback({ label }),
};
}
@@ -118,10 +105,20 @@ const communityAccounts = [
"atmosphere.community",
...communityDefinitions.map((community) => community.handle),
];
-const LIVE_FEED_EVENTS_TTL_MS = 1000 * 60 * 5;
+const ACTIVITY_CACHE_TTL_MS = 1000 * 60 * 5;
const LIMIT_RECORDS_PER_SOURCE = 200;
const MAX_PAGES_PER_SOURCE = 2;
+const forum = defineLiveCollection({
+ loader: createDiscourseActivityLoader({
+ baseUrl: "https://discourse.atprotocol.community",
+ source: { type: "latest" },
+ limit: 10,
+ cacheTtlMs: ACTIVITY_CACHE_TTL_MS,
+ }),
+ schema: discourseActivityTopicSchema,
+});
+
// Warm the source-profile cache once at server boot. Per-record transformers
// in lib/live-handlers (e.g. site.standard.document → getProfile('atmosphere.community'),
// shared-content → getProfile(author handle)) become cache hits for our curated set.
@@ -131,7 +128,7 @@ await prefetchSourceProfiles(communityAccounts);
const feed = defineAtProtoLiveCollection({
outputSchema: feedOutputSchema,
- cacheTtl: LIVE_FEED_EVENTS_TTL_MS,
+ cacheTtl: ACTIVITY_CACHE_TTL_MS,
sources: [
// The site's own curated posts use site.standard.document; community accounts share
// links via community.opensocial.sharedContent. Both feed into the same output schema
@@ -170,7 +167,7 @@ const feed = defineAtProtoLiveCollection({
const events = defineAtProtoLiveCollection({
outputSchema: eventsOutputSchema,
- cacheTtl: LIVE_FEED_EVENTS_TTL_MS,
+ cacheTtl: ACTIVITY_CACHE_TTL_MS,
sources: [
// Two paths into the events list:
// 1. Native calendar records authored by the community itself.
@@ -281,4 +278,4 @@ const communities = defineLiveCollection({
}),
});
-export const collections = { feed, events, communities };
+export const collections = { feed, events, communities, forum };
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 611f84f..d5bdeff 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -7,8 +7,10 @@ import PostCard from "../components/content/PostCard.astro";
import EventCard from "../components/events/EventCard.astro";
import CommunityCard from "../components/communities/CommunityCard.astro";
import GetInvolved from "../components/layout/GetInvolved.astro";
+import ForumActivityItem from "../components/content/ForumActivityItem.astro";
import { getLiveCollection } from "astro:content";
import { dedupeFeedEntries } from "../lib/community/dedup-feed";
+
const communitiesResult = await getLiveCollection("communities");
const communities = (communitiesResult.entries ?? []).map(
(entry) => entry.data,
@@ -31,6 +33,9 @@ const events = (eventsResult.entries ?? [])
.filter((event) => !event.isPast)
.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
.slice(0, 4);
+
+const forumResult = await getLiveCollection("forum");
+const forumTopics = (forumResult.entries ?? []).map((entry) => entry.data);
---
@@ -62,6 +67,44 @@ const events = (eventsResult.entries ?? [])
+
+
+
+
+ {
+ forumTopics.length > 0 ? (
+
+ {forumTopics.map((topic) => )}
+
+ ) : (
+
+ Forum activity is unavailable right now. You can still{" "}
+
+ visit Discourse
+ .
+
+ )
+ }
+
+
+
@@ -120,6 +163,17 @@ const events = (eventsResult.entries ?? [])
gap: var(--space-md);
}
+ .forum-header {
+ align-items: flex-end;
+ }
+
+ .forum-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ column-gap: var(--space-2xl);
+ list-style: none;
+ }
+
.community-row {
display: flex;
flex-wrap: wrap;
@@ -134,4 +188,17 @@ const events = (eventsResult.entries ?? [])
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
+
+ @media (max-width: 760px) {
+ .forum-list {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
+
+ @media (max-width: 520px) {
+ .forum-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ }
diff --git a/src/styles/global.css b/src/styles/global.css
index 7bd45c4..7f041f3 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -117,7 +117,7 @@ a:focus-visible {
border-radius: 2px;
}
-a[target="_blank"]:not(.btn):not(.hero-secondary):not(.post-card__link):not(.community-pill--link):not(.highlight-card):not(.event-card__details)::after {
+a[target="_blank"]:not(.btn):not(.hero-secondary):not(.post-card__link):not(.community-pill--link):not(.highlight-card):not(.event-card__details):not([data-external-icon="hidden"])::after {
content: "";
display: inline-block;
width: 0.95em;