From 3e137f5880c0335d579894508fa521d0cfdfc2a6 Mon Sep 17 00:00:00 2001 From: Haseeb MernStack Date: Tue, 27 Oct 2026 12:54:00 +0500 Subject: [PATCH] feat: three-column layout, live news, direct messages, contact page --- .env.example | 4 + apps/api/src/config/env.ts | 3 + apps/api/src/controllers/contactController.ts | 37 + apps/api/src/controllers/messageController.ts | 189 +++++ apps/api/src/controllers/newsController.ts | 18 + apps/api/src/middleware/rateLimit.ts | 29 + apps/api/src/models/ContactMessage.ts | 10 + apps/api/src/models/Conversation.ts | 19 + apps/api/src/models/Message.ts | 12 + apps/api/src/routes/index.ts | 40 +- apps/api/src/services/newsService.ts | 199 ++++++ apps/api/src/tests/messages.test.ts | 148 ++++ apps/api/src/tests/news.test.ts | 110 +++ apps/web/src/App.tsx | 30 + apps/web/src/components/Header.tsx | 81 +-- apps/web/src/components/LeftNav.tsx | 80 +++ apps/web/src/components/NewsPanel.tsx | 49 ++ apps/web/src/components/RightRail.tsx | 20 + apps/web/src/hooks/useMessages.ts | 73 ++ apps/web/src/hooks/useNews.ts | 14 + apps/web/src/pages/ContactPage.tsx | 72 ++ apps/web/src/pages/MessagesPage.tsx | 268 +++++++ apps/web/src/pages/NewsPage.tsx | 55 ++ apps/web/src/pages/ProfilePage.tsx | 21 +- apps/web/src/styles/global.css | 661 ++++++++++++++---- docs/NEWS_AND_MESSAGES.md | 24 + packages/shared/src/index.ts | 58 ++ 27 files changed, 2134 insertions(+), 190 deletions(-) create mode 100644 apps/api/src/controllers/contactController.ts create mode 100644 apps/api/src/controllers/messageController.ts create mode 100644 apps/api/src/controllers/newsController.ts create mode 100644 apps/api/src/models/ContactMessage.ts create mode 100644 apps/api/src/models/Conversation.ts create mode 100644 apps/api/src/models/Message.ts create mode 100644 apps/api/src/services/newsService.ts create mode 100644 apps/api/src/tests/messages.test.ts create mode 100644 apps/api/src/tests/news.test.ts create mode 100644 apps/web/src/components/LeftNav.tsx create mode 100644 apps/web/src/components/NewsPanel.tsx create mode 100644 apps/web/src/components/RightRail.tsx create mode 100644 apps/web/src/hooks/useMessages.ts create mode 100644 apps/web/src/hooks/useNews.ts create mode 100644 apps/web/src/pages/ContactPage.tsx create mode 100644 apps/web/src/pages/MessagesPage.tsx create mode 100644 apps/web/src/pages/NewsPage.tsx create mode 100644 docs/NEWS_AND_MESSAGES.md diff --git a/.env.example b/.env.example index 7a9f4d0..6cf3bdb 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,7 @@ LEMONSQUEEZY_PLUS_VARIANT_ID= PLUS_PRICE_LABEL="$5 / month" # Platform fee on paid-post sales for Plus creators (free creators pay PLATFORM_FEE_PERCENT) PLUS_FEE_PERCENT=10 + +# Contact page: messages are always saved in the database. Set this to also get them by email +# (needs RESEND_API_KEY above). +CONTACT_EMAIL= diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index cd2647d..ddc6fbf 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -51,6 +51,9 @@ const schema = z.object({ LEMONSQUEEZY_PLUS_VARIANT_ID: z.string().optional(), // Shown on the Plus page. The real price lives in Lemon Squeezy; keep this text in sync. PLUS_PRICE_LABEL: z.string().optional().transform((v) => v?.trim() || "$5 / month"), + // Where messages from the Contact page are emailed (needs RESEND_API_KEY). They are always + // saved in the database too. + CONTACT_EMAIL: z.string().optional().transform((v) => v?.trim() || undefined), // "true" blocks posting, liking, commenting and following until the email is verified. REQUIRE_VERIFIED_EMAIL: z .enum(["true", "false"]) diff --git a/apps/api/src/controllers/contactController.ts b/apps/api/src/controllers/contactController.ts new file mode 100644 index 0000000..9d65e44 --- /dev/null +++ b/apps/api/src/controllers/contactController.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from "express"; +import { env } from "../config/env.js"; +import { logger } from "../config/logger.js"; +import { ContactMessage } from "../models/ContactMessage.js"; +import { sendMail } from "../services/mailService.js"; + +const escapeHtml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export async function send(req: Request, res: Response) { + const { name, email, message, website } = req.body as { + name: string; + email: string; + message: string; + website?: string; + }; + + // Honeypot filled in: pretend it worked so bots learn nothing. + if (website) return void res.status(201).json({ ok: true }); + + await ContactMessage.create({ name, email, message }); + + if (env.CONTACT_EMAIL) { + // Best effort: the message is already saved, so a mail failure must not fail the request. + try { + await sendMail({ + to: env.CONTACT_EMAIL, + subject: `Commune contact: ${name}`.slice(0, 120), + text: `From: ${name} <${email}>\n\n${message}`, + html: `

${escapeHtml(name)} <${escapeHtml(email)}>

${escapeHtml(message).replace(/\n/g, "
")}

`, + }); + } catch (err) { + logger.error({ err: String(err) }, "Could not email contact message"); + } + } + res.status(201).json({ ok: true }); +} diff --git a/apps/api/src/controllers/messageController.ts b/apps/api/src/controllers/messageController.ts new file mode 100644 index 0000000..8167d29 --- /dev/null +++ b/apps/api/src/controllers/messageController.ts @@ -0,0 +1,189 @@ +import type { Request, Response } from "express"; +import mongoose from "mongoose"; +import type { ConversationDTO, MessageDTO, MessagePage } from "@commune/shared"; +import { Conversation } from "../models/Conversation.js"; +import { Message } from "../models/Message.js"; +import { User } from "../models/User.js"; +import { badRequest, forbidden, notFound } from "../utils/errors.js"; +import { isPlusActive } from "../utils/plan.js"; +import { findByUsername } from "./userController.js"; + +const PREVIEW_LENGTH = 120; +const USER_FIELDS = "username bio avatarUrl plusUntil banned"; + +interface LeanUser { + _id: unknown; + username: string; + bio?: string; + avatarUrl?: string | null; + plusUntil?: Date | null; +} +interface LeanConversation { + _id: unknown; + participants: unknown[]; + lastMessageText?: string; + lastMessageSender?: unknown; + lastMessageAt: Date; + unread?: Record | null; +} +interface LeanMessage { + _id: unknown; + conversation: unknown; + sender: unknown; + text: string; + createdAt: Date; +} + +const str = (v: unknown) => String(v); +const pairKey = (a: string, b: string) => [a, b].sort().join(":"); + +function toMessageDTO(m: LeanMessage): MessageDTO { + return { + id: str(m._id), + conversationId: str(m.conversation), + senderId: str(m.sender), + text: m.text, + createdAt: m.createdAt.toISOString(), + }; +} + +function toConversationDTO(c: LeanConversation, me: string, other: LeanUser): ConversationDTO { + const unread = c.unread && typeof c.unread === "object" ? Number(c.unread[me] ?? 0) : 0; + return { + id: str(c._id), + other: { + id: str(other._id), + username: other.username, + bio: other.bio ?? "", + avatarUrl: other.avatarUrl ?? null, + plus: isPlusActive(other.plusUntil), + }, + lastMessage: c.lastMessageText + ? { + text: c.lastMessageText, + senderId: str(c.lastMessageSender), + createdAt: c.lastMessageAt.toISOString(), + } + : null, + lastMessageAt: c.lastMessageAt.toISOString(), + unread, + }; +} + +/** Loads a conversation and makes sure the caller is one of its two participants. */ +async function loadConversation(id: string | undefined, me: string) { + if (!id || !mongoose.isValidObjectId(id)) throw notFound("Conversation not found"); + const convo = (await Conversation.findById(id).lean()) as LeanConversation | null; + // Same answer for "doesn't exist" and "not yours" so ids can't be probed. + if (!convo || !convo.participants.some((p) => str(p) === me)) throw notFound("Conversation not found"); + const otherId = convo.participants.map(str).find((p) => p !== me); + return { convo, otherId: otherId ?? me }; +} + +async function saveMessage(convoId: string, me: string, otherId: string, text: string) { + const message = await Message.create({ conversation: convoId, sender: me, text }); + await Conversation.updateOne( + { _id: convoId }, + { + $set: { + lastMessageText: text.slice(0, PREVIEW_LENGTH), + lastMessageSender: me, + lastMessageAt: message.createdAt, + }, + $inc: { [`unread.${otherId}`]: 1 }, + }, + ); + return toMessageDTO(message.toObject() as unknown as LeanMessage); +} + +/** GET /messages: your conversations, most recent first. */ +export async function listConversations(req: Request, res: Response) { + const me = req.userId!; + const convos = (await Conversation.find({ participants: me }) + .sort({ lastMessageAt: -1 }) + .limit(50) + .lean()) as unknown as LeanConversation[]; + + const otherIds = convos.map((c) => c.participants.map(str).find((p) => p !== me) ?? me); + const users = (await User.find({ _id: { $in: otherIds } }) + .select(USER_FIELDS) + .lean()) as unknown as (LeanUser & { banned?: boolean })[]; + const byId = new Map(users.map((u) => [str(u._id), u])); + + const out: ConversationDTO[] = []; + convos.forEach((c, i) => { + const other = byId.get(otherIds[i]!); + if (other && !other.banned) out.push(toConversationDTO(c, me, other)); + }); + res.json(out); +} + +/** GET /messages/unread-count: how many conversations have unread messages. */ +export async function unreadCount(req: Request, res: Response) { + const me = req.userId!; + const count = await Conversation.countDocuments({ + participants: me, + [`unread.${me}`]: { $gt: 0 }, + }); + res.json({ count }); +} + +/** GET /messages/:id: a page of messages (newest page first request; older via cursor). */ +export async function getMessages(req: Request, res: Response) { + const me = req.userId!; + const { convo, otherId } = await loadConversation(req.params.id, me); + const { cursor, limit } = res.locals.query as { cursor?: string; limit: number }; + + const rows = (await Message.find({ + conversation: str(convo._id), + ...(cursor ? { createdAt: { $lt: new Date(cursor) } } : {}), + }) + .sort({ createdAt: -1 }) + .limit(limit + 1) + .lean()) as unknown as LeanMessage[]; + + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); // newest first + const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null; + + // Opening the latest page counts as reading the conversation. + if (!cursor && Number(convo.unread?.[me] ?? 0) > 0) { + await Conversation.updateOne({ _id: str(convo._id) }, { $set: { [`unread.${me}`]: 0 } }); + } + + const other = (await User.findById(otherId).select(USER_FIELDS).lean()) as unknown as LeanUser | null; + const result: MessagePage & { conversation?: ConversationDTO } = { + items: page.reverse().map(toMessageDTO), // oldest first for display + nextCursor, + }; + if (!cursor && other) result.conversation = toConversationDTO(convo, me, other); + res.json(result); +} + +/** POST /messages/:id: reply in an existing conversation. */ +export async function send(req: Request, res: Response) { + const me = req.userId!; + const { convo, otherId } = await loadConversation(req.params.id, me); + const other = (await User.findById(otherId).select("banned").lean()) as { banned?: boolean } | null; + if (!other || other.banned) throw forbidden("You can't message this account"); + res.status(201).json(await saveMessage(str(convo._id), me, otherId, req.body.text)); +} + +/** POST /messages/with/:username: send to a person, creating the conversation if needed. */ +export async function start(req: Request, res: Response) { + const me = req.userId!; + const target = await findByUsername(req.params.username); + const targetId = str(target._id); + if (targetId === me) throw badRequest("You can't message yourself"); + if (target.banned) throw forbidden("You can't message this account"); + + const key = pairKey(me, targetId); + const convo = (await Conversation.findOneAndUpdate( + { key }, + { $setOnInsert: { key, participants: [me, targetId], lastMessageAt: new Date() } }, + { upsert: true, new: true }, + ).lean()) as unknown as LeanConversation; + + const message = await saveMessage(str(convo._id), me, targetId, req.body.text); + res.status(201).json({ conversationId: str(convo._id), message }); +} diff --git a/apps/api/src/controllers/newsController.ts b/apps/api/src/controllers/newsController.ts new file mode 100644 index 0000000..a2205c8 --- /dev/null +++ b/apps/api/src/controllers/newsController.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from "express"; +import type { NewsTopic } from "@commune/shared"; +import { logger } from "../config/logger.js"; +import { getNews } from "../services/newsService.js"; +import { AppError } from "../utils/errors.js"; + +/** Public. The CDN may keep a copy for 10 minutes, so most visitors never reach this code. */ +export async function list(_req: Request, res: Response) { + const { topic } = res.locals.query as { topic: NewsTopic }; + try { + const items = await getNews(topic); + res.set("Cache-Control", "public, max-age=60, s-maxage=600, stale-while-revalidate=3600"); + res.json(items); + } catch (err) { + logger.error({ err: String(err), topic }, "News unavailable"); + throw new AppError(503, "News is unavailable right now, try again in a few minutes."); + } +} diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index 6cee41c..cda0082 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -95,3 +95,32 @@ export const communityLimiter = rateLimit({ skip, message: { error: "You're creating communities too fast, try again later." }, }); + +/** Sending direct messages. */ +export const messageLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + limit: 60, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, + message: { error: "You're sending messages too fast, slow down." }, +}); + +/** Starting conversations with new people (the main spam vector for direct messages). */ +export const newChatLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 20, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, + message: { error: "You've started a lot of conversations, try again later." }, +}); + +export const contactLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, + message: { error: "Too many messages, try again later." }, +}); diff --git a/apps/api/src/models/ContactMessage.ts b/apps/api/src/models/ContactMessage.ts new file mode 100644 index 0000000..3a1aeb2 --- /dev/null +++ b/apps/api/src/models/ContactMessage.ts @@ -0,0 +1,10 @@ +import { Schema, model } from "mongoose"; + +const contactMessageSchema = new Schema({ + name: { type: String, required: true, maxlength: 60 }, + email: { type: String, required: true, maxlength: 120 }, + message: { type: String, required: true, maxlength: 2000 }, + createdAt: { type: Date, default: Date.now }, +}); + +export const ContactMessage = model("ContactMessage", contactMessageSchema); diff --git a/apps/api/src/models/Conversation.ts b/apps/api/src/models/Conversation.ts new file mode 100644 index 0000000..a049550 --- /dev/null +++ b/apps/api/src/models/Conversation.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +/** + * A one-to-one chat. `key` is the two user ids, sorted and joined, so a pair of people can + * only ever have one conversation (and concurrent "start chat" requests cannot create two). + */ +const conversationSchema = new Schema({ + key: { type: String, required: true, unique: true }, + participants: { type: [{ type: Schema.Types.ObjectId, ref: "User" }], required: true }, + lastMessageText: { type: String, default: "" }, + lastMessageSender: { type: Schema.Types.ObjectId, ref: "User", default: null }, + lastMessageAt: { type: Date, default: Date.now }, + // Unread count per participant, keyed by user id. + unread: { type: Map, of: Number, default: {} }, +}); + +conversationSchema.index({ participants: 1, lastMessageAt: -1 }); + +export const Conversation = model("Conversation", conversationSchema); diff --git a/apps/api/src/models/Message.ts b/apps/api/src/models/Message.ts new file mode 100644 index 0000000..05d958c --- /dev/null +++ b/apps/api/src/models/Message.ts @@ -0,0 +1,12 @@ +import { Schema, model } from "mongoose"; + +const messageSchema = new Schema({ + conversation: { type: Schema.Types.ObjectId, ref: "Conversation", required: true }, + sender: { type: Schema.Types.ObjectId, ref: "User", required: true }, + text: { type: String, required: true, maxlength: 1000 }, + createdAt: { type: Date, default: Date.now }, +}); + +messageSchema.index({ conversation: 1, createdAt: -1 }); + +export const Message = model("Message", messageSchema); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index f99766e..5599438 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,6 +1,10 @@ import { Router } from "express"; import { banUserSchema, + contactSchema, + messagesQuerySchema, + newsQuerySchema, + sendMessageSchema, communityListSchema, createCommentSchema, createCommunitySchema, @@ -19,6 +23,9 @@ import { verifyEmailSchema, } from "@commune/shared"; import * as account from "../controllers/accountController.js"; +import * as contact from "../controllers/contactController.js"; +import * as messages from "../controllers/messageController.js"; +import * as news from "../controllers/newsController.js"; import * as billing from "../controllers/billingController.js"; import * as insights from "../controllers/insightsController.js"; import * as admin from "../controllers/adminController.js"; @@ -33,7 +40,7 @@ import * as search from "../controllers/searchController.js"; import * as uploads from "../controllers/uploadController.js"; import * as users from "../controllers/userController.js"; import { requireActive, requireAdmin, requireAuth, requirePlus } from "../middleware/auth.js"; -import { authLimiter, checkoutLimiter, communityLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter, viewLimiter } from "../middleware/rateLimit.js"; +import { authLimiter, checkoutLimiter, contactLimiter, messageLimiter, newChatLimiter, communityLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter, viewLimiter } from "../middleware/rateLimit.js"; import { parseImageUpload } from "../middleware/upload.js"; import { validate } from "../middleware/validate.js"; import { asyncHandler } from "../utils/errors.js"; @@ -240,3 +247,34 @@ router.post( asyncHandler(admin.banUserByUsername), ); router.post("/admin/users/:username/unban", ...adminOnly, asyncHandler(admin.unbanUserByUsername)); + +// News (public: guests see it too) and the Contact form +router.get("/news", publicLimiter, validate(newsQuerySchema, "query"), asyncHandler(news.list)); +router.post("/contact", contactLimiter, validate(contactSchema), asyncHandler(contact.send)); + +// Direct messages. Fixed paths ("/messages/unread-count", "/messages/with/:username") come +// before "/messages/:id". +router.get("/messages", requireAuth, asyncHandler(messages.listConversations)); +router.get("/messages/unread-count", requireAuth, asyncHandler(messages.unreadCount)); +router.post( + "/messages/with/:username", + requireAuth, + newChatLimiter, + validate(sendMessageSchema), + requireActive, + asyncHandler(messages.start), +); +router.get( + "/messages/:id", + requireAuth, + validate(messagesQuerySchema, "query"), + asyncHandler(messages.getMessages), +); +router.post( + "/messages/:id", + requireAuth, + messageLimiter, + validate(sendMessageSchema), + requireActive, + asyncHandler(messages.send), +); diff --git a/apps/api/src/services/newsService.ts b/apps/api/src/services/newsService.ts new file mode 100644 index 0000000..1004f8c --- /dev/null +++ b/apps/api/src/services/newsService.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto"; +import type { NewsItemDTO, NewsTopic } from "@commune/shared"; +import { logger } from "../config/logger.js"; + +/** + * Live English news from the public RSS feeds of established publishers. Only the headline, + * link, source name and time are used; every item links out to the publisher's own page. + */ +interface Feed { + source: string; + url: string; +} + +export const FEEDS: Record = { + top: [ + { source: "BBC News", url: "https://feeds.bbci.co.uk/news/rss.xml" }, + { source: "NPR", url: "https://feeds.npr.org/1001/rss.xml" }, + { source: "The Guardian", url: "https://www.theguardian.com/international/rss" }, + { source: "Al Jazeera", url: "https://www.aljazeera.com/xml/rss/all.xml" }, + ], + world: [ + { source: "BBC News", url: "https://feeds.bbci.co.uk/news/world/rss.xml" }, + { source: "The Guardian", url: "https://www.theguardian.com/world/rss" }, + { source: "NPR", url: "https://feeds.npr.org/1004/rss.xml" }, + ], + technology: [ + { source: "BBC News", url: "https://feeds.bbci.co.uk/news/technology/rss.xml" }, + { source: "The Guardian", url: "https://www.theguardian.com/uk/technology/rss" }, + { source: "NPR", url: "https://feeds.npr.org/1019/rss.xml" }, + ], + business: [ + { source: "BBC News", url: "https://feeds.bbci.co.uk/news/business/rss.xml" }, + { source: "The Guardian", url: "https://www.theguardian.com/uk/business/rss" }, + { source: "NPR", url: "https://feeds.npr.org/1006/rss.xml" }, + ], + sport: [ + { source: "BBC Sport", url: "https://feeds.bbci.co.uk/sport/rss.xml" }, + { source: "The Guardian", url: "https://www.theguardian.com/uk/sport/rss" }, + ], +}; + +const FRESH_MS = 10 * 60 * 1000; // serve from cache for 10 minutes +const STALE_MS = 6 * 60 * 60 * 1000; // if every feed fails, an older copy is still better than nothing +const MAX_ITEMS = 30; +const MAX_PER_SOURCE = 12; +const MAX_FEED_BYTES = 2_000_000; +const FETCH_TIMEOUT_MS = 5000; + +const ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + rsquo: "’", + lsquo: "‘", + rdquo: "”", + ldquo: "“", + ndash: "–", + mdash: "—", + hellip: "…", +}; + +/** Removes CDATA wrappers and tags, decodes entities and collapses whitespace. */ +export function cleanText(raw: string): string { + return raw + .replace(//g, "$1") + .replace(/<[^>]*>/g, " ") + .replace(/&#x([0-9a-f]+);/gi, (_, h: string) => safeChar(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d: string) => safeChar(parseInt(d, 10))) + .replace(/&([a-z]+);/gi, (m, n: string) => ENTITIES[n.toLowerCase()] ?? m) + .replace(/\s+/g, " ") + .trim(); +} + +function safeChar(code: number): string { + try { + return code > 0 && code < 0x110000 ? String.fromCodePoint(code) : ""; + } catch { + return ""; + } +} + +function tag(block: string, name: string): string | null { + const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)`, "i").exec(block); + return m ? m[1]! : null; +} + +/** Accepts only absolute http(s) links, so a feed can never inject a javascript: URL. */ +export function safeUrl(raw: string | null): string | null { + if (!raw) return null; + try { + const u = new URL(cleanText(raw)); + return u.protocol === "https:" || u.protocol === "http:" ? u.toString() : null; + } catch { + return null; + } +} + +/** Parses an RSS 2.0 or Atom document into items. Bad items are skipped, never thrown on. */ +export function parseFeed(xml: string, source: string): NewsItemDTO[] { + const items: NewsItemDTO[] = []; + const blocks = xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/gi) ?? []; + for (const block of blocks) { + const title = cleanText(tag(block, "title") ?? ""); + let link = safeUrl(tag(block, "link")); + if (!link) { + // Atom: + const href = /]*\shref=["']([^"']+)["'][^>]*>/i.exec(block); + link = safeUrl(href ? href[1]! : null); + } + const dateRaw = tag(block, "pubDate") ?? tag(block, "published") ?? tag(block, "updated") ?? tag(block, "dc:date"); + const time = dateRaw ? Date.parse(cleanText(dateRaw)) : NaN; + if (!title || !link || Number.isNaN(time)) continue; + items.push({ + id: createHash("sha1").update(link).digest("hex").slice(0, 16), + title: title.slice(0, 240), + link, + source, + publishedAt: new Date(time).toISOString(), + }); + } + return items; +} + +async function fetchFeed(feed: Feed): Promise { + const res = await fetch(feed.url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + "User-Agent": "CommuneNewsBot/1.0 (+https://commune-mu.vercel.app)", + Accept: "application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.5", + }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const length = Number(res.headers.get("content-length") ?? 0); + if (length > MAX_FEED_BYTES) throw new Error("feed too large"); + const xml = (await res.text()).slice(0, MAX_FEED_BYTES); + return parseFeed(xml, feed.source).slice(0, MAX_PER_SOURCE); +} + +/** Newest first, one entry per headline, capped. */ +export function mergeItems(lists: NewsItemDTO[][], now = Date.now()): NewsItemDTO[] { + const seen = new Set(); + return lists + .flat() + .filter((i) => Date.parse(i.publishedAt) <= now + 10 * 60 * 1000) // ignore far-future dates + .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)) + .filter((i) => { + const key = i.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, MAX_ITEMS); +} + +interface CacheEntry { + items: NewsItemDTO[]; + at: number; +} +const cache = new Map(); +const inFlight = new Map>(); + +export function clearNewsCache() { + cache.clear(); + inFlight.clear(); +} + +async function refresh(topic: NewsTopic): Promise { + const results = await Promise.allSettled(FEEDS[topic].map(fetchFeed)); + const lists: NewsItemDTO[][] = []; + results.forEach((r, i) => { + if (r.status === "fulfilled") lists.push(r.value); + else logger.warn({ feed: FEEDS[topic][i]!.url, err: String(r.reason) }, "News feed failed"); + }); + const items = mergeItems(lists); + if (items.length === 0) throw new Error("No news feeds could be read"); + cache.set(topic, { items, at: Date.now() }); + return items; +} + +/** Returns cached news when fresh, otherwise refreshes (one request at a time per topic). */ +export async function getNews(topic: NewsTopic): Promise { + const hit = cache.get(topic); + if (hit && Date.now() - hit.at < FRESH_MS) return hit.items; + + let pending = inFlight.get(topic); + if (!pending) { + pending = refresh(topic).finally(() => inFlight.delete(topic)); + inFlight.set(topic, pending); + } + try { + return await pending; + } catch (err) { + if (hit && Date.now() - hit.at < STALE_MS) return hit.items; + throw err; + } +} diff --git a/apps/api/src/tests/messages.test.ts b/apps/api/src/tests/messages.test.ts new file mode 100644 index 0000000..c5cefba --- /dev/null +++ b/apps/api/src/tests/messages.test.ts @@ -0,0 +1,148 @@ +import mongoose from "mongoose"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { contactSchema, sendMessageSchema } from "@commune/shared"; +import { createApp } from "../app.js"; +import { Conversation } from "../models/Conversation.js"; +import { ContactMessage } from "../models/ContactMessage.js"; +import { Message } from "../models/Message.js"; +import { User } from "../models/User.js"; +import { signAccessToken } from "../utils/tokens.js"; + +const app = createApp(); +const oid = () => new mongoose.Types.ObjectId(); +const ME = oid().toString(); +const OTHER = oid(); +const auth = { Authorization: `Bearer ${signAccessToken(ME)}` }; + +afterEach(() => vi.restoreAllMocks()); + +const leanOf = (value: unknown) => ({ lean: () => Promise.resolve(value) }); +const findOne = (user: unknown) => + vi.spyOn(User, "findOne").mockImplementation((() => ({ collation: () => leanOf(user) })) as never); + +describe("schemas", () => { + it("trim and bound message text", () => { + expect(sendMessageSchema.parse({ text: " hi " }).text).toBe("hi"); + expect(sendMessageSchema.safeParse({ text: " " }).success).toBe(false); + expect(sendMessageSchema.safeParse({ text: "x".repeat(1001) }).success).toBe(false); + }); + it("contact form needs a real email and message", () => { + const ok = { name: "Sam", email: "SAM@Example.com", message: "Hello there, friend" }; + expect(contactSchema.parse(ok).email).toBe("sam@example.com"); + expect(contactSchema.safeParse({ ...ok, email: "nope" }).success).toBe(false); + expect(contactSchema.safeParse({ ...ok, message: "short" }).success).toBe(false); + }); +}); + +describe("message routes", () => { + it("require a login", async () => { + expect((await request(app).get("/api/messages")).status).toBe(401); + expect((await request(app).get("/api/messages/unread-count")).status).toBe(401); + expect((await request(app).post("/api/messages/with/sam").send({ text: "hi" })).status).toBe(401); + }); + + it("reject empty messages before touching the database", async () => { + const res = await request(app).post("/api/messages/with/sam").set(auth).send({ text: " " }); + expect(res.status).toBe(400); + }); + + it("refuse to message yourself", async () => { + findOne({ _id: new mongoose.Types.ObjectId(ME), username: "myself", banned: false }); + const res = await request(app).post("/api/messages/with/myself").set(auth).send({ text: "hi" }); + expect(res.status).toBe(400); + }); + + it("refuse to message a suspended account", async () => { + findOne({ _id: OTHER, username: "sam", banned: true }); + const res = await request(app).post("/api/messages/with/sam").set(auth).send({ text: "hi" }); + expect(res.status).toBe(403); + }); + + it("start a conversation with a stable pair key and count the unread for the recipient", async () => { + findOne({ _id: OTHER, username: "sam", banned: false }); + const convoId = oid(); + const upsert = vi.spyOn(Conversation, "findOneAndUpdate").mockImplementation((() => + leanOf({ _id: convoId, participants: [ME, OTHER], lastMessageAt: new Date() })) as never); + vi.spyOn(Message, "create").mockResolvedValue({ + _id: oid(), + conversation: convoId, + sender: ME, + text: "hello", + createdAt: new Date(), + toObject() { + return this; + }, + } as never); + const update = vi.spyOn(Conversation, "updateOne").mockResolvedValue({} as never); + + const res = await request(app).post("/api/messages/with/sam").set(auth).send({ text: "hello" }); + expect(res.status).toBe(201); + expect(res.body.conversationId).toBe(convoId.toString()); + expect(res.body.message.text).toBe("hello"); + + const key = ((upsert.mock.calls[0] as unknown as [{ key: string }])[0]).key; + expect(key).toBe([ME, OTHER.toString()].sort().join(":")); + const change = (update.mock.calls[0] as unknown as [unknown, { $inc: Record }])[1]; + expect(change.$inc).toEqual({ [`unread.${OTHER.toString()}`]: 1 }); + }); + + it("hide other people's conversations (404, not 403)", async () => { + vi.spyOn(Conversation, "findById").mockImplementation((() => + leanOf({ _id: oid(), participants: [oid(), oid()], lastMessageAt: new Date() })) as never); + const id = oid().toString(); + expect((await request(app).get(`/api/messages/${id}`).set(auth)).status).toBe(404); + expect((await request(app).post(`/api/messages/${id}`).set(auth).send({ text: "hi" })).status).toBe(404); + }); + + it("404 on malformed conversation ids", async () => { + expect((await request(app).get("/api/messages/not-an-id").set(auth)).status).toBe(404); + }); + + it("list messages oldest first, with a cursor when there are more", async () => { + const convoId = oid(); + vi.spyOn(Conversation, "findById").mockImplementation((() => + leanOf({ _id: convoId, participants: [ME, OTHER], lastMessageAt: new Date(), unread: { [ME]: 2 } })) as never); + const base = Date.parse("2026-10-26T10:00:00Z"); + const rows = [3, 2, 1].map((i) => ({ + _id: oid(), + conversation: convoId, + sender: OTHER, + text: `m${i}`, + createdAt: new Date(base + i * 1000), + })); + vi.spyOn(Message, "find").mockImplementation((() => ({ + sort: () => ({ limit: () => leanOf(rows) }), + })) as never); + const update = vi.spyOn(Conversation, "updateOne").mockResolvedValue({} as never); + + const res = await request(app).get(`/api/messages/${convoId}?limit=2`).set(auth); + expect(res.status).toBe(200); + expect(res.body.items.map((m: { text: string }) => m.text)).toEqual(["m2", "m3"]); + expect(res.body.nextCursor).toBe(new Date(base + 2000).toISOString()); + // reading the latest page clears the unread counter + expect(update).toHaveBeenCalledWith({ _id: convoId.toString() }, { $set: { [`unread.${ME}`]: 0 } }); + }); +}); + +describe("POST /api/contact", () => { + const body = { name: "Sam", email: "sam@example.com", message: "I love this site, thanks" }; + + it("saves a valid message", async () => { + const create = vi.spyOn(ContactMessage, "create").mockResolvedValue({} as never); + const res = await request(app).post("/api/contact").send(body); + expect(res.status).toBe(201); + expect(create).toHaveBeenCalledOnce(); + }); + + it("silently ignores bots that fill the honeypot", async () => { + const create = vi.spyOn(ContactMessage, "create").mockResolvedValue({} as never); + const res = await request(app).post("/api/contact").send({ ...body, website: "http://spam.example" }); + expect(res.status).toBe(201); + expect(create).not.toHaveBeenCalled(); + }); + + it("validates input", async () => { + expect((await request(app).post("/api/contact").send({ ...body, email: "x" })).status).toBe(400); + }); +}); diff --git a/apps/api/src/tests/news.test.ts b/apps/api/src/tests/news.test.ts new file mode 100644 index 0000000..f08efbb --- /dev/null +++ b/apps/api/src/tests/news.test.ts @@ -0,0 +1,110 @@ +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../app.js"; +import { cleanText, clearNewsCache, getNews, mergeItems, parseFeed, safeUrl } from "../services/newsService.js"; + +const app = createApp(); + +const RSS = ` +<![CDATA[Markets rally & recover]]>https://example.com/aMon, 26 Oct 2026 10:00:00 GMT +Second storyhttps://example.com/bMon, 26 Oct 2026 11:00:00 GMT +Bad linkjavascript:alert(1)Mon, 26 Oct 2026 11:00:00 GMT +No datehttps://example.com/c +`; + +const ATOM = `Atom story2026-10-26T12:00:00Z`; + +beforeEach(() => clearNewsCache()); +afterEach(() => vi.unstubAllGlobals()); + +describe("news parsing", () => { + it("cleans CDATA, entities and tags", () => { + expect(cleanText("bold]]>")).toBe("A & B 'c' bold"); + expect(cleanText("x A — y")).toBe("x A — y"); + }); + + it("only accepts http(s) links", () => { + expect(safeUrl("https://a.com/x")).toBe("https://a.com/x"); + expect(safeUrl("javascript:alert(1)")).toBeNull(); + expect(safeUrl("not a url")).toBeNull(); + expect(safeUrl(null)).toBeNull(); + }); + + it("parses RSS, skipping items with bad links or dates", () => { + const items = parseFeed(RSS, "Test"); + expect(items.map((i) => i.title)).toEqual(["Markets rally & recover", "Second story"]); + expect(items[0]).toMatchObject({ source: "Test", link: "https://example.com/a" }); + expect(items[0]!.id).toHaveLength(16); + }); + + it("parses Atom", () => { + const [item] = parseFeed(ATOM, "Atom"); + expect(item).toMatchObject({ title: "Atom story", link: "https://example.com/atom" }); + }); + + it("returns nothing for garbage", () => { + expect(parseFeed("nope", "x")).toEqual([]); + }); + + it("merges newest first and drops duplicate headlines", () => { + const a = parseFeed(RSS, "A"); + const dup = parseFeed(RSS.replace("https://example.com/b", "https://other.com/b"), "B"); + const merged = mergeItems([a, dup], Date.parse("2026-10-27T00:00:00Z")); + expect(merged.map((i) => i.title)).toEqual(["Second story", "Markets rally & recover"]); + }); + + it("drops items dated far in the future", () => { + const merged = mergeItems([parseFeed(RSS, "A")], Date.parse("2026-10-26T10:30:00Z")); + expect(merged.map((i) => i.title)).toEqual(["Markets rally & recover"]); + }); +}); + +describe("getNews cache", () => { + it("fetches once, then serves from cache", async () => { + const fetchMock = vi.fn(async () => new Response(RSS, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + await getNews("top"); + const calls = fetchMock.mock.calls.length; + expect(calls).toBeGreaterThan(0); + await getNews("top"); + expect(fetchMock.mock.calls.length).toBe(calls); + }); + + it("survives one failing feed", async () => { + let n = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + n += 1; + return n === 1 ? new Response("", { status: 500 }) : new Response(RSS, { status: 200 }); + }), + ); + expect((await getNews("world")).length).toBeGreaterThan(0); + }); + + it("throws when every feed fails and nothing is cached", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 500 }))); + await expect(getNews("sport")).rejects.toThrow(); + }); +}); + +describe("GET /api/news", () => { + it("is public and returns items with cache headers", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(RSS, { status: 200 }))); + const res = await request(app).get("/api/news"); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toContain("s-maxage=600"); + expect(res.body[0]).toHaveProperty("title"); + }); + + it("rejects unknown topics", async () => { + expect((await request(app).get("/api/news?topic=gossip")).status).toBe(400); + }); + + it("answers 503 without leaking details when feeds are down", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("secret network detail"); })); + const res = await request(app).get("/api/news?topic=business"); + expect(res.status).toBe(503); + expect(JSON.stringify(res.body)).not.toContain("secret"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e849c9b..fdd2511 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -18,6 +18,11 @@ import PwaBanner from "./components/PwaBanner"; import ExplorePage from "./pages/ExplorePage"; import CommunitiesPage from "./pages/CommunitiesPage"; import CommunityPage from "./pages/CommunityPage"; +import LeftNav from "./components/LeftNav"; +import RightRail from "./components/RightRail"; +import MessagesPage from "./pages/MessagesPage"; +import NewsPage from "./pages/NewsPage"; +import ContactPage from "./pages/ContactPage"; import PlusPage from "./pages/PlusPage"; import InsightsPage from "./pages/InsightsPage"; @@ -36,12 +41,17 @@ function Home() { export default function App() { const { pathname } = useLocation(); + const { user } = useAuth(); const authPage = AUTH_PATHS.includes(pathname); + // Messages need room for two panes, so that page drops the news column. + const wide = pathname.startsWith("/messages"); return ( <>
{!authPage && } +
+ {!authPage && user && }
{authPage && ( @@ -129,9 +139,29 @@ export default function App() { } /> + } /> + } /> + + + + } + /> + + + + } + /> } />
+ {!authPage && !wide && } +
); } diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index 5471d13..99a84b8 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, NavLink, useLocation } from "react-router-dom"; import { useAuth } from "../store/auth"; import { useUnreadCount } from "../hooks/useSocial"; +import { useUnreadMessages } from "../hooks/useMessages"; import Avatar from "./Avatar"; import PlusBadge from "./PlusBadge"; import SearchBox from "./SearchBox"; @@ -11,16 +12,7 @@ export const AUTH_PATHS = ["/login", "/register", "/forgot-password", "/reset-pa function MenuIcon({ open }: { open: boolean }) { return ( - + {open ? : } ); @@ -31,7 +23,9 @@ export default function Header() { const { pathname } = useLocation(); const unread = useUnreadCount(Boolean(user)); const [open, setOpen] = useState(false); + const msgs = useUnreadMessages(Boolean(user)); const count = unread.data?.count ?? 0; + const msgCount = msgs.data?.count ?? 0; const countLabel = count > 99 ? "99+" : String(count); // Close the drawer whenever the page changes. @@ -82,6 +76,9 @@ export default function Header() { Explore + + News + Log in @@ -92,28 +89,6 @@ export default function Header() { )} {user && ( <> - {user.role === "admin" && ( - - Admin - - )} - - Communities - - `${navClass({ isActive })} plus-link`} - > - {user.plus ? "Insights" : "✦ Plus"} - - - 🔔 - {count > 0 && {countLabel}} - @{user.username} @@ -128,11 +103,13 @@ export default function Header() { {/* Mobile / tablet controls */}
{user && ( - + + ✉️ + {msgCount > 0 && {msgCount}} + + )} + {user && ( + 🔔 {count > 0 && {countLabel}} @@ -151,19 +128,8 @@ export default function Header() { {/* Slide-in menu (right side) */}
- +

+ )} + + {news.data?.slice(0, limit).map((n) => ( + + + {n.source} · {timeAgo(n.publishedAt)} ago + + {n.title} + + ))} + + + Show more + + + ); +} diff --git a/apps/web/src/components/RightRail.tsx b/apps/web/src/components/RightRail.tsx new file mode 100644 index 0000000..69a3ea0 --- /dev/null +++ b/apps/web/src/components/RightRail.tsx @@ -0,0 +1,20 @@ +import { Link } from "react-router-dom"; +import NewsPanel from "./NewsPanel"; + +/** Right column on wide screens: live news and small footer links. */ +export default function RightRail() { + return ( + + ); +} diff --git a/apps/web/src/hooks/useMessages.ts b/apps/web/src/hooks/useMessages.ts new file mode 100644 index 0000000..751171c --- /dev/null +++ b/apps/web/src/hooks/useMessages.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ConversationDTO, MessageDTO, MessagePage } from "@commune/shared"; +import { api } from "../lib/api"; + +export type ThreadPage = MessagePage & { conversation?: ConversationDTO }; + +export function useConversations(enabled = true) { + return useQuery({ + queryKey: ["conversations"], + queryFn: () => api("/messages"), + enabled, + refetchInterval: 15_000, + }); +} + +/** Number of conversations with unread messages (for the badge). */ +export function useUnreadMessages(enabled: boolean) { + return useQuery({ + queryKey: ["unread-messages"], + queryFn: () => api<{ count: number }>("/messages/unread-count"), + enabled, + refetchInterval: 30_000, + }); +} + +/** The newest page of a conversation, re-checked every 4 seconds while the tab is open. */ +export function useThread(id: string | undefined) { + const qc = useQueryClient(); + return useQuery({ + queryKey: ["thread", id], + enabled: Boolean(id), + queryFn: async () => { + const page = await api(`/messages/${id}`); + // Opening a thread marks it read on the server, so refresh the badges when it mattered. + if ((page.conversation?.unread ?? 0) > 0) { + void qc.invalidateQueries({ queryKey: ["unread-messages"] }); + void qc.invalidateQueries({ queryKey: ["conversations"] }); + } + return page; + }, + refetchInterval: 4_000, + refetchIntervalInBackground: false, + staleTime: 0, + }); +} + +export function loadOlder(id: string, cursor: string) { + return api(`/messages/${id}?cursor=${encodeURIComponent(cursor)}`); +} + +export function useSendMessage(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (text: string) => + api(`/messages/${id}`, { method: "POST", body: { text } }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["thread", id] }); + void qc.invalidateQueries({ queryKey: ["conversations"] }); + }, + }); +} + +export function useStartChat(username: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (text: string) => + api<{ conversationId: string; message: MessageDTO }>(`/messages/with/${encodeURIComponent(username)}`, { + method: "POST", + body: { text }, + }), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["conversations"] }), + }); +} diff --git a/apps/web/src/hooks/useNews.ts b/apps/web/src/hooks/useNews.ts new file mode 100644 index 0000000..a086770 --- /dev/null +++ b/apps/web/src/hooks/useNews.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { NewsItemDTO, NewsTopic } from "@commune/shared"; +import { api } from "../lib/api"; + +/** Live headlines. The API caches for 10 minutes, so checking every 5 is cheap. */ +export function useNews(topic: NewsTopic = "top") { + return useQuery({ + queryKey: ["news", topic], + queryFn: () => api(`/news?topic=${topic}`), + staleTime: 5 * 60_000, + refetchInterval: 5 * 60_000, + refetchIntervalInBackground: false, + }); +} diff --git a/apps/web/src/pages/ContactPage.tsx b/apps/web/src/pages/ContactPage.tsx new file mode 100644 index 0000000..9a1ac51 --- /dev/null +++ b/apps/web/src/pages/ContactPage.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { contactSchema } from "@commune/shared"; +import { api } from "../lib/api"; +import { useAuth } from "../store/auth"; + +export default function ContactPage() { + const { user } = useAuth(); + const [fields, setFields] = useState({ name: user?.username ?? "", email: "", message: "", website: "" }); + const [error, setError] = useState(null); + const [state, setState] = useState<"idle" | "sending" | "sent">("idle"); + + const set = (k: keyof typeof fields) => (e: { target: { value: string } }) => + setFields((f) => ({ ...f, [k]: e.target.value })); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + const parsed = contactSchema.safeParse(fields); + if (!parsed.success) return setError(parsed.error.issues[0]?.message ?? "Check your details"); + setState("sending"); + try { + await api("/contact", { method: "POST", body: parsed.data }); + setState("sent"); + } catch (err) { + setState("idle"); + setError(err instanceof Error ? err.message : "Could not send your message"); + } + }; + + if (state === "sent") { + return ( +
+

Thank you!

+

Your message was sent. We'll reply by email as soon as we can.

+
+ ); + } + + return ( +
void submit(e)} noValidate> +

Contact us

+

Questions, feedback or a bug to report? Write to us.

+ + +