From b3e5b22faa07fd243637b58662dae462e606e386 Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Tue, 21 Jul 2026 12:54:17 -0700 Subject: [PATCH] Show recent Discourse activity on the homepage --- .../content/ForumActivityItem.astro | 158 ++++++ src/components/content/PostCard.astro | 8 +- src/lib/avatar-fallback.ts | 37 ++ src/lib/discourse-activity.test.ts | 453 ++++++++++++++++++ src/lib/discourse-activity.ts | 396 +++++++++++++++ src/live.config.ts | 51 +- src/pages/index.astro | 67 +++ src/styles/global.css | 2 +- 8 files changed, 1137 insertions(+), 35 deletions(-) create mode 100644 src/components/content/ForumActivityItem.astro create mode 100644 src/lib/avatar-fallback.ts create mode 100644 src/lib/discourse-activity.test.ts create mode 100644 src/lib/discourse-activity.ts 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 ?? []) + +
    +
    +
    +
    +

    On the forum

    +

    + The latest active conversations from the ATProtocol Community. +

    +
    + Visit Discourse → +
    + { + 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; -- 2.51.2