From 8ff154edb4f29d76cb913501b92fb89b88379c72 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 04:11:31 -0400 Subject: [PATCH 1/7] RichText: render links, lists and paragraphs in imported text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/rich-text.ts parses html-to-text output into blocks and inline runs: paragraphs and line breaks, '* '/'- ' list items, '[https://…]' as a 'host ↗' link, '[text](url)' markdown links and bare URLs. Only http/https/mailto hrefs are produced; links open in a new tab with noopener noreferrer; no dangerouslySetInnerHTML. Used for event descriptions and org About; card previews use the same plainPreview so bracketed URLs don't eat the clamped lines. Unit tests included. --- .../(app)/events/[id]/event-detail-client.tsx | 5 +- .../(app)/orgs/[id]/org-profile-client.tsx | 5 +- apps/web/src/components/common/rich-text.tsx | 73 ++++++++ apps/web/src/lib/event-media.ts | 10 +- apps/web/src/lib/rich-text.test.ts | 101 +++++++++++ apps/web/src/lib/rich-text.ts | 163 ++++++++++++++++++ 6 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/common/rich-text.tsx create mode 100644 apps/web/src/lib/rich-text.test.ts create mode 100644 apps/web/src/lib/rich-text.ts diff --git a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx index 1b3abae..302fe00 100644 --- a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx +++ b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx @@ -26,6 +26,7 @@ import { toggleSave, } from "~/actions/events"; import { OrgAvatar } from "~/components/common/org-avatar"; +import { RichText } from "~/components/common/rich-text"; import { AttendeesDialog } from "~/components/events/attendees-dialog"; import { EventCoverArt } from "~/components/events/event-cover-art"; import { MiniEventList } from "~/components/events/mini-event-list"; @@ -288,9 +289,7 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro )} -

- {event.description} -

+
{event.source === "myprincetonu" || event.source === "listserv" ? ( diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx index 3e07499..c021a4d 100644 --- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx +++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx @@ -19,6 +19,7 @@ import { toast } from "sonner"; import { type UserSearchResult, searchUsers } from "~/actions/friends"; import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/actions/orgs"; import { OrgAvatar } from "~/components/common/org-avatar"; +import { RichText } from "~/components/common/rich-text"; import { SearchInput } from "~/components/common/search-input"; import { EmptyState } from "~/components/common/states"; import { EventCard } from "~/components/events/event-card"; @@ -231,9 +232,7 @@ export function OrgProfileClient({ org }: { org: OrgDetail }) { {org.description && (

About

-

- {org.description} -

+
)} diff --git a/apps/web/src/components/common/rich-text.tsx b/apps/web/src/components/common/rich-text.tsx new file mode 100644 index 0000000..f2b64ff --- /dev/null +++ b/apps/web/src/components/common/rich-text.tsx @@ -0,0 +1,73 @@ +import { Fragment } from "react"; +import { type Inline, parseRichText } from "~/lib/rich-text"; +import { cn } from "~/lib/utils"; + +function InlineContent({ parts }: { parts: Inline[] }) { + return ( + <> + {parts.map((part, i) => + part.type === "text" ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: parsed text runs have no identity + {part.text} + ) : ( + + {part.label} + + ), + )} + + ); +} + +/** + * Renders imported plain text (event descriptions, org About, listserv + * emails) with paragraphs, line breaks, bullet lists and safe links — all as + * React elements, never `dangerouslySetInnerHTML`. See `lib/rich-text.ts`. + */ +export function RichText({ + text, + className, +}: { text: string | null | undefined; className?: string }) { + const blocks = parseRichText(text); + if (blocks.length === 0) return null; + return ( +
+ {blocks.map((block, i) => + block.type === "paragraph" ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: blocks are positional +

+ {block.lines.map((line, j) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + + {j > 0 &&
} + +
+ ))} +

+ ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: blocks are positional +
    + {block.items.map((item, j) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: items are positional +
  • + +
  • + ))} +
+ ), + )} +
+ ); +} diff --git a/apps/web/src/lib/event-media.ts b/apps/web/src/lib/event-media.ts index d816a6f..8b8b24e 100644 --- a/apps/web/src/lib/event-media.ts +++ b/apps/web/src/lib/event-media.ts @@ -1,3 +1,5 @@ +import { plainPreview } from "~/lib/rich-text"; + /** * Helpers for presenting imported event content on cards. */ @@ -22,11 +24,5 @@ export function eventPhotoUrl(flyerUrl: string | null | undefined): string | nul * included, is on the event page) and whitespace is collapsed. */ export function descriptionPreview(description: string | null | undefined): string { - if (!description) return ""; - return description - .replace(/\[(https?:\/\/[^\]\s]+)\]/g, " ") - .replace(/)\]]+>?/g, " ") - .replace(/\(\s*\)/g, " ") - .replace(/\s+/g, " ") - .trim(); + return plainPreview(description); } diff --git a/apps/web/src/lib/rich-text.test.ts b/apps/web/src/lib/rich-text.test.ts new file mode 100644 index 0000000..c74faa6 --- /dev/null +++ b/apps/web/src/lib/rich-text.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { linkLabelFor, parseInline, parseRichText, plainPreview, safeHref } from "./rich-text"; + +describe("safeHref", () => { + test("allows http, https and mailto", () => { + expect(safeHref("https://a.com/x")).toBe("https://a.com/x"); + expect(safeHref("http://a.com")).toBe("http://a.com/"); + expect(safeHref("mailto:x@princeton.edu")).toBe("mailto:x@princeton.edu"); + }); + test("rejects other schemes and junk", () => { + expect(safeHref("javascript:alert(1)")).toBeNull(); + expect(safeHref("data:text/html,hi")).toBeNull(); + expect(safeHref("not a url")).toBeNull(); + }); +}); + +describe("parseInline", () => { + test("html-to-text bracketed URL becomes a host-labelled link", () => { + const out = parseInline("Use Agreement [https://cglink.me/2gi/s54144]. Save time"); + expect(out).toEqual([ + { type: "text", text: "Use Agreement " }, + { type: "link", href: "https://cglink.me/2gi/s54144", label: "cglink.me ↗", external: true }, + { type: "text", text: ". Save time" }, + ]); + }); + + test("markdown link keeps its text", () => { + const out = parseInline("RSVP [here](https://forms.gle/abc) now"); + expect(out[1]).toEqual({ + type: "link", + href: "https://forms.gle/abc", + label: "here", + external: true, + }); + }); + + test("bare URL is linkified without trailing punctuation", () => { + const out = parseInline("See https://tigerapps.org/about, then reply."); + expect(out).toEqual([ + { type: "text", text: "See " }, + { + type: "link", + href: "https://tigerapps.org/about", + label: "https://tigerapps.org/about", + external: true, + }, + { type: "text", text: ", then reply." }, + ]); + }); + + test("unsafe markdown link degrades to text", () => { + // Not matched as a link token at all (scheme isn't http/https/mailto). + expect(parseInline("[x](javascript:alert(1))")).toEqual([ + { type: "text", text: "[x](javascript:alert(1))" }, + ]); + }); + + test("mailto in brackets is labelled with the address", () => { + const out = parseInline("Email [mailto:it.admin@tigerapps.org]"); + expect(out[1]).toMatchObject({ + type: "link", + label: "it.admin@tigerapps.org", + external: false, + }); + }); +}); + +describe("parseRichText", () => { + test("paragraphs, line breaks and lists", () => { + const blocks = parseRichText("Hello\nworld\n\n* one\n- two\nafter"); + expect(blocks).toHaveLength(3); + expect(blocks[0]).toEqual({ + type: "paragraph", + lines: [[{ type: "text", text: "Hello" }], [{ type: "text", text: "world" }]], + }); + expect(blocks[1]).toEqual({ + type: "list", + items: [[{ type: "text", text: "one" }], [{ type: "text", text: "two" }]], + }); + expect(blocks[2]).toEqual({ type: "paragraph", lines: [[{ type: "text", text: "after" }]] }); + }); + + test("empty input", () => { + expect(parseRichText("")).toEqual([]); + expect(parseRichText(null)).toEqual([]); + }); +}); + +describe("plainPreview", () => { + test("strips bracketed URLs and keeps markdown labels", () => { + expect( + plainPreview("Sign [https://x.com/a]. Or [the form](https://y.com). Go https://z.com"), + ).toBe("Sign. Or the form. Go"); + }); +}); + +describe("linkLabelFor", () => { + test("drops www", () => { + expect(linkLabelFor("https://www.princeton.edu/x")).toBe("princeton.edu ↗"); + }); +}); diff --git a/apps/web/src/lib/rich-text.ts b/apps/web/src/lib/rich-text.ts new file mode 100644 index 0000000..8b8cc9f --- /dev/null +++ b/apps/web/src/lib/rich-text.ts @@ -0,0 +1,163 @@ +/** + * Tiny, safe parser for the plain text we get from imported events, org + * descriptions and listserv emails (html-to-text output). Produces a tree the + * `RichText` component renders with React elements — never raw HTML. + * + * Supported: + * - paragraphs (blank-line separated) and single line breaks + * - list items: lines starting with "* " or "- " + * - "[https://…]" → link labelled "host ↗" (html-to-text's style) + * - "[text](https://…)" → link labelled "text" + * - bare http(s) URLs → link + * Only http:, https: and mailto: hrefs are ever produced. + */ + +export type Inline = + | { type: "text"; text: string } + | { type: "link"; href: string; label: string; external: boolean }; + +export type Block = { type: "paragraph"; lines: Inline[][] } | { type: "list"; items: Inline[][] }; + +const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +/** Returns a normalized href if it's an allowed http/https/mailto URL, else null. */ +export function safeHref(raw: string): string | null { + const candidate = raw.trim(); + try { + const url = new URL(candidate); + if (!ALLOWED_PROTOCOLS.has(url.protocol)) return null; + if (url.protocol !== "mailto:" && !url.hostname) return null; + return url.toString(); + } catch { + return null; + } +} + +/** "https://www.cglink.me/2gi/s54144" → "cglink.me ↗"; mailto → the address. */ +export function linkLabelFor(href: string): string { + try { + const url = new URL(href); + if (url.protocol === "mailto:") return decodeURIComponent(url.pathname); + return `${url.hostname.replace(/^www\./, "")} ↗`; + } catch { + return href; + } +} + +// Order matters: markdown links, then bracketed URLs, then angle-bracketed and bare URLs. +const TOKEN = + /\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)|\[((?:https?:\/\/|mailto:)[^\]\s]+)\]|<((?:https?:\/\/|mailto:)[^\s>]+)>|(https?:\/\/[^\s<>()[\]"']+)/g; + +/** Trailing punctuation that belongs to the sentence, not the URL. */ +function splitTrailing(url: string): [string, string] { + const m = url.match(/[.,;:!?]+$/); + if (!m) return [url, ""]; + return [url.slice(0, -m[0].length), m[0]]; +} + +export function parseInline(line: string): Inline[] { + const out: Inline[] = []; + let last = 0; + const pushText = (text: string) => { + if (!text) return; + const prev = out[out.length - 1]; + if (prev?.type === "text") prev.text += text; + else out.push({ type: "text", text }); + }; + + for (const match of line.matchAll(TOKEN)) { + const index = match.index ?? 0; + pushText(line.slice(last, index)); + last = index + match[0].length; + + const [, mdText, mdHref, bracketHref, angleHref, bare] = match; + if (mdText && mdHref) { + const href = safeHref(mdHref); + if (href) + out.push({ + type: "link", + href, + label: mdText.trim(), + external: !href.startsWith("mailto:"), + }); + else pushText(mdText); + } else if (bracketHref || angleHref) { + const href = safeHref((bracketHref ?? angleHref) as string); + if (href) + out.push({ + type: "link", + href, + label: linkLabelFor(href), + external: !href.startsWith("mailto:"), + }); + else pushText(match[0]); + } else if (bare) { + const [urlPart, trailing] = splitTrailing(bare); + const href = safeHref(urlPart); + if (href) { + out.push({ type: "link", href, label: urlPart, external: true }); + pushText(trailing); + } else { + pushText(bare); + } + } + } + pushText(line.slice(last)); + return out; +} + +const LIST_ITEM = /^\s*[*-]\s+(.*)$/; + +export function parseRichText(input: string | null | undefined): Block[] { + if (!input) return []; + const text = input.replace(/\r\n?/g, "\n").trim(); + if (!text) return []; + + const blocks: Block[] = []; + for (const chunk of text.split(/\n\s*\n+/)) { + const lines = chunk.split("\n"); + let paragraph: Inline[][] = []; + let list: Inline[][] = []; + + const flushParagraph = () => { + if (paragraph.length > 0) blocks.push({ type: "paragraph", lines: paragraph }); + paragraph = []; + }; + const flushList = () => { + if (list.length > 0) blocks.push({ type: "list", items: list }); + list = []; + }; + + for (const raw of lines) { + const item = raw.match(LIST_ITEM); + if (item) { + flushParagraph(); + list.push(parseInline((item[1] ?? "").trim())); + } else if (raw.trim()) { + flushList(); + paragraph.push(parseInline(raw.trim())); + } + } + flushParagraph(); + flushList(); + } + return blocks; +} + +/** + * Plain one-paragraph preview for clamped card text: link markup is reduced + * to its label (markdown) or dropped (bare/bracketed URLs) so URLs don't eat + * the visible lines. + */ +export function plainPreview(input: string | null | undefined): string { + if (!input) return ""; + return input + .replace(/\[([^\]\n]+)\]\((?:https?:\/\/|mailto:)[^\s)]+\)/g, "$1") + .replace(/\[(?:https?:\/\/|mailto:)[^\]\s]+\]/g, " ") + .replace(/)\]]+>?/g, " ") + .replace(/^\s*[*-]\s+/gm, "• ") + .replace(/\(\s*\)/g, " ") + .replace(/\s+/g, " ") + .replace(/\s+([.,;:!?])/g, "$1") + .trim(); +} From 19a00f4fbe67ab638e723c2eddfe7014a8685034 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 04:14:01 -0400 Subject: [PATCH 2/7] Org pages: recent emails from InboxEngine with an in-page reader - env: optional server vars INBOX_ENGINE_URL / INBOX_ENGINE_TOKEN (env.ts, runtimeEnv, both .env examples) - lib/inbox-engine.ts: server-side fetch with Bearer auth and a 5s timeout; lists cached 5 min, single emails per request; missing config, timeouts, non-2xx and malformed payloads resolve to empty/null so the org page never fails; payloads are validated field by field - actions/inbox.ts: getInboxEmail for the reader (signed-in, rate limited, never throws) - getOrg exposes externalId (MyPrincetonU 'mpu:') - OrgEmails: compact rows (subject, sender, NY-time date, list badges, preview), total count and 'Browse all N emails in TigerInbox'; a row opens a Sheet that lazily loads the email and renders it with RichText, with Open in TigerInbox / listserv archive links - Placement: emails lead (and events collapse to one line) when the org has no upcoming events; otherwise they follow Upcoming events --- .env.example | 2 +- apps/web/.env.local.example | 4 + apps/web/src/actions/inbox.ts | 18 ++ apps/web/src/actions/orgs.ts | 3 + .../(app)/orgs/[id]/org-profile-client.tsx | 127 ++++++--- apps/web/src/app/(app)/orgs/[id]/page.tsx | 13 +- apps/web/src/components/orgs/org-emails.tsx | 247 ++++++++++++++++++ apps/web/src/env.ts | 5 + apps/web/src/lib/inbox-engine.ts | 137 ++++++++++ apps/web/src/lib/rate-limit.ts | 1 + 10 files changed, 512 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/actions/inbox.ts create mode 100644 apps/web/src/components/orgs/org-emails.tsx create mode 100644 apps/web/src/lib/inbox-engine.ts diff --git a/.env.example b/.env.example index 2293b74..15aad8f 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ NEXT_PUBLIC_MAPBOX_TOKEN=YOUR_CAMPUS_MAPBOX_TOKEN_HERE NEXT_PUBLIC_CAMPUS_MAP_TOKEN=YOUR_CAMPUS_MAP_TOKEN_HERE NEXT_PUBLIC_CAMPUS_MAP_STYLE=YOUR_CAMPUS_MAP_STYLE_HERE -# ----- InboxEngine (org/venue/event sync, apps/database) ----- +# ----- InboxEngine (org/venue/event sync in apps/database; org emails in apps/web) ----- # INBOX_ENGINE_URL=https://inbox-engine.tigerapps.org # INBOX_ENGINE_TOKEN=ask-a-tigerapps-admin diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index ce63720..e809c30 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -28,3 +28,7 @@ NEXT_PUBLIC_CAMPUS_MAP_STYLE=YOUR_CAMPUS_MAP_STYLE_HERE # AWS S3 (image uploads) # AWS_S3_BUCKET=the-forum-uploads # AWS_REGION=us-east-1 + +# InboxEngine API — powers "Recent emails" on org pages (optional) +# INBOX_ENGINE_URL=http://127.0.0.1:8300 +# INBOX_ENGINE_TOKEN=local-forum-token-0123456789abcdef diff --git a/apps/web/src/actions/inbox.ts b/apps/web/src/actions/inbox.ts new file mode 100644 index 0000000..290f9a1 --- /dev/null +++ b/apps/web/src/actions/inbox.ts @@ -0,0 +1,18 @@ +"use server"; + +import { auth } from "~/auth"; +import { type InboxMessageDetail, getEmailById } from "~/lib/inbox-engine"; +import { checkRateLimit } from "~/lib/rate-limit"; + +/** + * Loads one full email for the org-page reader. Signed-in users only + * (rate-limited); returns null — never throws — when the email can't be + * fetched, so the reader can show a friendly fallback. + */ +export async function getInboxEmail(id: string): Promise { + const session = await auth(); + if (!session?.user?.id) return null; + if (typeof id !== "string") return null; + if (!checkRateLimit("readEmail", session.user.id).ok) return null; + return getEmailById(id); +} diff --git a/apps/web/src/actions/orgs.ts b/apps/web/src/actions/orgs.ts index 7fbf64b..e5b7396 100644 --- a/apps/web/src/actions/orgs.ts +++ b/apps/web/src/actions/orgs.ts @@ -57,6 +57,8 @@ export interface OrgDetail { acronym: string | null; tagline: string | null; groupType: string | null; + /** MyPrincetonU id ("mpu:70043") for imported groups — keys their emails in InboxEngine. */ + externalId: string | null; /** The group's MyPrincetonU page. */ groupUrl: string | null; website: string | null; @@ -262,6 +264,7 @@ export async function getOrg(orgId: string): Promise { acronym: org.acronym, tagline: org.tagline, groupType: org.groupType, + externalId: org.externalId, groupUrl: org.groupUrl, website: org.website, contactEmail: org.contactEmail, diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx index c021a4d..860f9a1 100644 --- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx +++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx @@ -24,7 +24,9 @@ import { SearchInput } from "~/components/common/search-input"; import { EmptyState } from "~/components/common/states"; import { EventCard } from "~/components/events/event-card"; import { PageShell } from "~/components/layout/page-shell"; +import { OrgEmails } from "~/components/orgs/org-emails"; import { Button } from "~/components/ui/button"; +import type { OrgEmails as OrgEmailsData } from "~/lib/inbox-engine"; const SOCIAL_LABELS: Record = { instagram: "Instagram", @@ -71,7 +73,16 @@ function SideSection({ title, children }: { title: string; children: React.React ); } -export function OrgProfileClient({ org }: { org: OrgDetail }) { +export function OrgProfileClient({ + org, + emails, + emailsBrowseUrl, +}: { + org: OrgDetail; + /** Recent listserv emails from InboxEngine (MyPrincetonU groups only). */ + emails: OrgEmailsData | null; + emailsBrowseUrl: string | null; +}) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [isFollowing, setIsFollowing] = useState(org.isFollowing); @@ -147,6 +158,67 @@ export function OrgProfileClient({ org }: { org: OrgDetail }) { } }; + const hasEmails = Boolean(emails && emails.messages.length > 0 && emailsBrowseUrl); + // No upcoming events? Then the org's emails are the most useful thing here. + const emailsFirst = hasEmails && org.upcomingEvents.length === 0; + const emailsSection = + hasEmails && emails && emailsBrowseUrl ? ( + + ) : null; + const eventsSection = ( +
+

+ Upcoming events + {org.upcomingEvents.length} +

+ {org.upcomingEvents.length > 0 ? ( +
+ {org.upcomingEvents.map((event, index) => ( + + ))} +
+ ) : hasEmails ? ( +

+ No upcoming events right now — their recent emails are above. +

+ ) : ( + + )} +
+ ); + const socials = Object.entries(org.socials).filter(([k, v]) => SOCIAL_LABELS[k] && v); return ( @@ -236,48 +308,17 @@ export function OrgProfileClient({ org }: { org: OrgDetail }) { )} -
-

- Upcoming events - {org.upcomingEvents.length} -

- {org.upcomingEvents.length > 0 ? ( -
- {org.upcomingEvents.map((event, index) => ( - - ))} -
- ) : ( - - )} -
+ {emailsFirst ? ( + <> + {emailsSection} + {eventsSection} + + ) : ( + <> + {eventsSection} + {emailsSection} + + )}