diff --git a/apps/main-site/src/app/(main-site)/m/[messageId]/markdown/route.ts b/apps/main-site/src/app/(main-site)/m/[messageId]/markdown/route.ts new file mode 100644 index 000000000..95e9b058d --- /dev/null +++ b/apps/main-site/src/app/(main-site)/m/[messageId]/markdown/route.ts @@ -0,0 +1,84 @@ +import { decodeCursor } from "@packages/ui/utils/cursor"; +import { Schema } from "effect"; +import { notFound, redirect } from "next/navigation"; +import { + fetchMessagePageHeaderData, + fetchMessagePageReplies, + type MessagePageReplies, +} from "../../../../../components/message-page-loader"; +import { + buildMessageMarkdown, + createMarkdownResponse, +} from "../../../../../lib/message-markdown"; + +type RouteParams = { + params: Promise<{ messageId: string }>; +}; + +function parseBigInt(value: string) { + return Schema.decodeUnknownOption(Schema.BigInt)(value); +} + +export async function GET( + request: Request, + { params }: RouteParams, +): Promise { + const { messageId } = await params; + + const parsed = parseBigInt(messageId); + if (parsed._tag === "None") { + notFound(); + } + + const url = new URL(request.url); + const cursorParam = url.searchParams.get("cursor"); + const cursor = cursorParam ? decodeCursor(cursorParam) : null; + + const headerData = await fetchMessagePageHeaderData(parsed.value); + + if (!headerData) { + notFound(); + } + + const hasThread = headerData.thread !== null; + const rootMessageDeleted = !headerData.firstMessage; + + if (rootMessageDeleted && !hasThread) { + notFound(); + } + + const canonicalId = headerData.canonicalId.toString(); + + if (canonicalId !== messageId) { + const redirectUrl = cursor + ? `/m/${canonicalId}.md?cursor=${cursorParam}` + : `/m/${canonicalId}.md`; + redirect(redirectUrl); + } + + const queryChannelId = headerData.threadId ?? headerData.channelId; + const afterMessageId = + headerData.threadId ?? headerData.firstMessage?.message.id; + + let replies: MessagePageReplies = { + page: [], + isDone: true, + continueCursor: "", + }; + + if (afterMessageId) { + replies = await fetchMessagePageReplies({ + channelId: queryChannelId, + after: afterMessageId, + cursor, + }); + } + + const markdown = buildMessageMarkdown({ + headerData, + replies, + messageId: canonicalId, + }); + + return createMarkdownResponse(markdown); +} diff --git a/apps/main-site/src/app/[domain]/m/[messageId]/markdown/route.ts b/apps/main-site/src/app/[domain]/m/[messageId]/markdown/route.ts new file mode 100644 index 000000000..58cfcad75 --- /dev/null +++ b/apps/main-site/src/app/[domain]/m/[messageId]/markdown/route.ts @@ -0,0 +1,99 @@ +import { Database } from "@packages/database/database"; +import { decodeCursor } from "@packages/ui/utils/cursor"; +import { Effect, Schema } from "effect"; +import { notFound, redirect } from "next/navigation"; +import { + fetchMessagePageReplies, + type MessagePageReplies, +} from "../../../../../components/message-page-loader"; +import { + buildMessageMarkdown, + createMarkdownResponse, +} from "../../../../../lib/message-markdown"; +import { runtime } from "../../../../../lib/runtime"; + +type RouteParams = { + params: Promise<{ domain: string; messageId: string }>; +}; + +function parseBigInt(value: string) { + return Schema.decodeUnknownOption(Schema.BigInt)(value); +} + +export async function GET( + request: Request, + { params }: RouteParams, +): Promise { + const { domain: encodedDomain, messageId } = await params; + const domain = decodeURIComponent(encodedDomain); + + const parsed = parseBigInt(messageId); + if (parsed._tag === "None") { + notFound(); + } + + const url = new URL(request.url); + const cursorParam = url.searchParams.get("cursor"); + const cursor = cursorParam ? decodeCursor(cursorParam) : null; + + const [tenantData, headerData] = await Effect.gen(function* () { + const database = yield* Database; + const tenant = yield* database.private.servers.getServerByDomain({ + domain, + }); + const header = yield* database.private.messages.getMessagePageHeaderData({ + messageId: parsed.value, + }); + return [tenant, header] as const; + }).pipe(runtime.runPromise); + + if (!tenantData?.server || !headerData) { + notFound(); + } + + if (headerData.server.discordId !== tenantData.server.discordId) { + notFound(); + } + + const hasThread = headerData.thread !== null; + const rootMessageDeleted = !headerData.firstMessage; + + if (rootMessageDeleted && !hasThread) { + notFound(); + } + + const canonicalId = headerData.canonicalId.toString(); + + if (canonicalId !== messageId) { + const redirectUrl = cursor + ? `/m/${canonicalId}.md?cursor=${cursorParam}` + : `/m/${canonicalId}.md`; + redirect(redirectUrl); + } + + const queryChannelId = headerData.threadId ?? headerData.channelId; + const afterMessageId = + headerData.threadId ?? headerData.firstMessage?.message.id; + + let replies: MessagePageReplies = { + page: [], + isDone: true, + continueCursor: "", + }; + + if (afterMessageId) { + replies = await fetchMessagePageReplies({ + channelId: queryChannelId, + after: afterMessageId, + cursor, + }); + } + + const markdown = buildMessageMarkdown({ + headerData, + replies, + messageId: canonicalId, + }); + + return createMarkdownResponse(markdown); +} diff --git a/apps/main-site/src/lib/message-markdown.ts b/apps/main-site/src/lib/message-markdown.ts new file mode 100644 index 000000000..24cfa2e63 --- /dev/null +++ b/apps/main-site/src/lib/message-markdown.ts @@ -0,0 +1,153 @@ +import { isImageAttachment } from "@packages/ui/utils/attachments"; +import { encodeCursor } from "@packages/ui/utils/cursor"; +import { getDate } from "@packages/ui/utils/snowflake"; +import type { + MessagePageHeaderData, + MessagePageReplies, +} from "../components/message-page-loader"; + +function formatDate(snowflake: bigint): string { + const date = getDate(snowflake); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatMessageContent( + message: { + message: { content: string | null }; + attachments: Array<{ url: string; filename: string; contentType?: string }>; + } | null, +): string { + if (!message) { + return "_Original message was deleted_"; + } + + const parts: Array = []; + + if (message.message.content) { + parts.push(message.message.content); + } + + const imageAttachments = message.attachments.filter((a) => + isImageAttachment(a), + ); + for (const attachment of imageAttachments) { + parts.push(`![${attachment.filename}](${attachment.url})`); + } + + const otherAttachments = message.attachments.filter( + (a) => !isImageAttachment(a), + ); + for (const attachment of otherAttachments) { + parts.push(`[${attachment.filename}](${attachment.url})`); + } + + return parts.join("\n\n") || "_No content_"; +} + +function formatReply( + reply: MessagePageReplies["page"][number], + isLast: boolean, +): string { + const authorName = reply.author?.name ?? "Unknown User"; + const date = formatDate(reply.message.id); + const content = formatMessageContent(reply); + + const lines = [`**@${authorName}** - ${date}`, "", content]; + + if (!isLast) { + lines.push("", "---"); + } + + return lines.join("\n"); +} + +const HIDDEN_AUTHOR_IDS = [958907348389339146n]; + +export function buildMessageMarkdown(data: { + headerData: MessagePageHeaderData; + replies: MessagePageReplies; + messageId: string; +}): string { + const { headerData, replies, messageId } = data; + + const title = + headerData.thread?.name ?? + headerData.firstMessage?.message.content?.slice(0, 100) ?? + headerData.channel.name; + + const postedDate = headerData.firstMessage + ? formatDate(headerData.firstMessage.message.id) + : null; + + const lines: Array = []; + + lines.push(`# ${title}`); + lines.push(""); + + const metaParts = [ + `**Server:** ${headerData.server.name}`, + `**Channel:** #${headerData.channel.name}`, + ]; + if (postedDate) { + metaParts.push(`**Posted:** ${postedDate}`); + } + lines.push(metaParts.join(" | ")); + lines.push(""); + lines.push("---"); + lines.push(""); + + lines.push("## Question"); + lines.push(""); + lines.push(formatMessageContent(headerData.firstMessage)); + lines.push(""); + lines.push("---"); + + if (headerData.solutionMessage) { + lines.push(""); + lines.push("## Solution ✓"); + lines.push(""); + lines.push(formatMessageContent(headerData.solutionMessage)); + lines.push(""); + lines.push("---"); + } + + if (replies.page.length > 0) { + lines.push(""); + lines.push("## Replies"); + lines.push(""); + + const filteredReplies = replies.page.filter( + (r) => !HIDDEN_AUTHOR_IDS.includes(r.message.authorId), + ); + + for (let i = 0; i < filteredReplies.length; i++) { + const reply = filteredReplies[i]; + if (!reply) continue; + const isLast = i === filteredReplies.length - 1 && replies.isDone; + lines.push(formatReply(reply, isLast)); + lines.push(""); + } + } + + if (!replies.isDone && replies.continueCursor) { + const encodedCursor = encodeCursor(replies.continueCursor); + lines.push(""); + lines.push( + `[Load more of the conversation](/m/${messageId}.md?cursor=${encodedCursor})`, + ); + } + + return lines.join("\n"); +} + +export function createMarkdownResponse(markdown: string): Response { + return new Response(markdown, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + }, + }); +} diff --git a/apps/main-site/src/proxy.ts b/apps/main-site/src/proxy.ts index d237602fe..f9e0a13de 100644 --- a/apps/main-site/src/proxy.ts +++ b/apps/main-site/src/proxy.ts @@ -28,6 +28,25 @@ export function proxy(request: NextRequest) { const path = pathname + search; const host = request.headers.get("host") ?? ""; + const acceptHeader = request.headers.get("accept") ?? ""; + const prefersMarkdown = + acceptHeader.includes("text/markdown") || + acceptHeader.includes("text/plain"); + + const mdExtensionMatch = pathname.match(/^\/m\/(\d+)\.md$/); + if (mdExtensionMatch) { + url.pathname = `/m/${mdExtensionMatch[1]}/markdown`; + return NextResponse.rewrite(url); + } + + if (prefersMarkdown) { + const messageMatch = pathname.match(/^\/m\/(\d+)$/); + if (messageMatch) { + url.pathname = `/m/${messageMatch[1]}/markdown`; + return NextResponse.rewrite(url); + } + } + if (pathname.startsWith("/og")) { return NextResponse.next(); }