/** * Socials module — federated social feed aggregator. * * Aggregates and displays social media activity across community members. * Supports ActivityPub, RSS, and manual link sharing. */ import { resolve } from "node:path"; import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises"; import { Hono } from "hono"; import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-data"; const routes = new Hono(); // ── Thread storage helpers ── const THREADS_BASE = resolve(process.env.FILES_DIR || "./data/files", "threads"); interface ThreadData { id: string; name: string; handle: string; title: string; tweets: string[]; imageUrl?: string; createdAt: number; updatedAt: number; } function generateThreadId(): string { const random = Math.random().toString(36).substring(2, 8); return `t-${Date.now()}-${random}`; } async function ensureThreadsDir(): Promise { await mkdir(THREADS_BASE, { recursive: true }); return THREADS_BASE; } async function loadThread(id: string): Promise { try { const dir = await ensureThreadsDir(); const raw = await readFile(resolve(dir, `${id}.json`), "utf-8"); return JSON.parse(raw); } catch { return null; } } async function saveThread(data: ThreadData): Promise { const dir = await ensureThreadsDir(); await writeFile(resolve(dir, `${data.id}.json`), JSON.stringify(data, null, 2)); } async function deleteThreadFile(id: string): Promise { try { const dir = await ensureThreadsDir(); await unlink(resolve(dir, `${id}.json`)); return true; } catch { return false; } } async function listThreads(): Promise> { const dir = await ensureThreadsDir(); const files = await readdir(dir); const threads: Array<{ id: string; title: string; tweetCount: number; updatedAt: number }> = []; for (const f of files) { if (!f.endsWith(".json")) continue; try { const raw = await readFile(resolve(dir, f), "utf-8"); const data: ThreadData = JSON.parse(raw); threads.push({ id: data.id, title: data.title, tweetCount: data.tweets.length, updatedAt: data.updatedAt }); } catch { /* skip corrupt files */ } } threads.sort((a, b) => b.updatedAt - a.updatedAt); return threads; } // ── API: Health ── routes.get("/api/health", (c) => { return c.json({ ok: true, module: "rsocials" }); }); // ── API: Info ── routes.get("/api/info", (c) => { return c.json({ module: "rsocials", description: "Federated social feed aggregator for communities", features: [ "ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline", ], }); }); // ── API: Feed — community social timeline ── routes.get("/api/feed", (c) => { // Demo feed items return c.json({ items: [ { id: "demo-1", type: "post", author: "Alice", content: "Just published our community governance proposal!", source: "fediverse", timestamp: new Date(Date.now() - 3600_000).toISOString(), likes: 12, replies: 3, }, { id: "demo-2", type: "link", author: "Bob", content: "Great article on local-first collaboration", url: "https://example.com/local-first", source: "shared", timestamp: new Date(Date.now() - 7200_000).toISOString(), likes: 8, replies: 1, }, { id: "demo-3", type: "post", author: "Carol", content: "Welcome new members! Check out rSpace's tools in the app switcher above.", source: "local", timestamp: new Date(Date.now() - 14400_000).toISOString(), likes: 24, replies: 7, }, ], demo: true, }); }); // ── API: Campaigns list ── routes.get("/api/campaigns", (c) => { const space = c.req.param("space") || "demo"; const campaign = MYCOFI_CAMPAIGN; return c.json({ campaigns: [ { id: campaign.id, title: campaign.title, description: campaign.description, duration: campaign.duration, platforms: campaign.platforms, postCount: campaign.posts.length, updated_at: "2026-02-21T09:00:00Z", url: `/${space}/rsocials/campaign`, }, ], }); }); // ── API: Thread CRUD ── routes.get("/api/threads", async (c) => { const threads = await listThreads(); return c.json({ threads }); }); routes.post("/api/threads", async (c) => { const { name, handle, title, tweets } = await c.req.json(); if (!tweets?.length) return c.json({ error: "tweets required" }, 400); const id = generateThreadId(); const now = Date.now(); const thread: ThreadData = { id, name: name || "Your Name", handle: handle || "@yourhandle", title: title || (tweets[0] || "").substring(0, 60), tweets, createdAt: now, updatedAt: now, }; await saveThread(thread); return c.json({ id }); }); routes.get("/api/threads/:id", async (c) => { const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const thread = await loadThread(id); if (!thread) return c.json({ error: "Thread not found" }, 404); return c.json(thread); }); routes.put("/api/threads/:id", async (c) => { const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const existing = await loadThread(id); if (!existing) return c.json({ error: "Thread not found" }, 404); const { name, handle, title, tweets } = await c.req.json(); if (name !== undefined) existing.name = name; if (handle !== undefined) existing.handle = handle; if (title !== undefined) existing.title = title; if (tweets?.length) existing.tweets = tweets; existing.updatedAt = Date.now(); await saveThread(existing); return c.json({ id }); }); routes.delete("/api/threads/:id", async (c) => { const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); // Try to delete associated preview image const thread = await loadThread(id); if (thread?.imageUrl) { const filename = thread.imageUrl.split("/").pop(); if (filename) { const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); try { await unlink(resolve(genDir, filename)); } catch {} } } const ok = await deleteThreadFile(id); if (!ok) return c.json({ error: "Thread not found" }, 404); return c.json({ ok: true }); }); routes.post("/api/threads/:id/image", async (c) => { const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const thread = await loadThread(id); if (!thread) return c.json({ error: "Thread not found" }, 404); const FAL_KEY = process.env.FAL_KEY || ""; if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); // Build prompt from first 2-3 tweets const summary = thread.tweets.slice(0, 3).join(" ").substring(0, 200); const prompt = `Social media thread preview card about: ${summary}. Dark themed, modern, minimal style with abstract shapes.`; const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt, image_size: "landscape_4_3", num_images: 1, safety_tolerance: "2", }), }); if (!falRes.ok) { console.error("[thread-image] fal.ai error:", await falRes.text()); return c.json({ error: "Image generation failed" }, 502); } const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; const cdnUrl = falData.images?.[0]?.url || falData.output?.url; if (!cdnUrl) return c.json({ error: "No image returned" }, 502); // Download and save locally const imgRes = await fetch(cdnUrl); if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); const imgBuffer = await imgRes.arrayBuffer(); const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await mkdir(genDir, { recursive: true }); const filename = `thread-${id}.png`; await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); const imageUrl = `/data/files/generated/${filename}`; thread.imageUrl = imageUrl; thread.updatedAt = Date.now(); await saveThread(thread); return c.json({ imageUrl }); }); // ── Demo feed data (server-rendered, no API calls) ── const DEMO_FEED = [ { username: "@alice", initial: "A", color: "#6366f1", content: "Just deployed the new rFunds river view! The enoughness score is such a powerful concept. \u{1F30A}", timeAgo: "2 hours ago", likes: 5, replies: 2, }, { username: "@bob", initial: "B", color: "#f59e0b", content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!", timeAgo: "5 hours ago", likes: 8, replies: 4, }, { username: "@carol", initial: "C", color: "#10b981", content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}", timeAgo: "1 day ago", likes: 12, replies: 3, }, { username: "@diana", initial: "D", color: "#ec4899", content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.", timeAgo: "1 day ago", likes: 7, replies: 5, }, { username: "@eve", initial: "E", color: "#14b8a6", content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!", timeAgo: "2 days ago", likes: 3, replies: 1, }, { username: "@frank", initial: "F", color: "#8b5cf6", content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}", timeAgo: "3 days ago", likes: 15, replies: 6, }, ]; function renderDemoFeedHTML(): string { const cards = DEMO_FEED.map( (post) => `
${post.initial}
${post.username}

${post.content}

${post.likes} ${post.replies}
`, ).join("\n"); return `

Social Feed DEMO

Open Full App

A preview of your community's social timeline

${cards}

This is demo data. Connect ActivityPub or RSS feeds in your own space.

`; } // ── Campaign page route ── function renderCampaignPage(space: string): string { const c = MYCOFI_CAMPAIGN; const phases = [1, 2, 3]; const phaseIcons = ["📣", "🚀", "📡"]; const phaseHTML = phases.map((phaseNum, i) => { const phasePosts = c.posts.filter((p) => p.phase === phaseNum); const phaseInfo = c.phases[i]; const postsHTML = phasePosts.map((post) => { const icon = PLATFORM_ICONS[post.platform] || post.platform; const color = PLATFORM_COLORS[post.platform] || "#64748b"; const statusClass = post.status === "scheduled" ? "campaign-status--scheduled" : "campaign-status--draft"; const date = new Date(post.scheduledAt); const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); const contentPreview = escapeHtml(post.content.length > 180 ? post.content.substring(0, 180) + "..." : post.content); const tags = post.hashtags.map((h) => `#${escapeHtml(h)}`).join(" "); return `
${icon} ${escapeHtml(post.status)}
Step ${post.stepNumber}

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

`; }).join("\n"); return `

${phaseIcons[i]} Phase ${phaseNum}: ${escapeHtml(phaseInfo.label)} ${escapeHtml(phaseInfo.days)}

${postsHTML}
`; }).join("\n"); return `
🍄

${escapeHtml(c.title)}

${escapeHtml(c.description)}

📅 ${escapeHtml(c.duration)} 📱 ${c.platforms.join(", ")} 📝 ${c.posts.length} posts across ${c.phases.length} phases
Open Thread Builder
${phaseHTML}
`; } const CAMPAIGN_CSS = ` .campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } .campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #334155; } .campaign-page__icon { font-size: 3rem; } .campaign-page__title { margin: 0; font-size: 1.5rem; color: #f1f5f9; } .campaign-page__desc { margin: 0.25rem 0 0.5rem; color: #94a3b8; font-size: 0.9rem; line-height: 1.5; } .campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: #64748b; } .campaign-phase { margin-bottom: 2rem; } .campaign-phase__title { font-size: 1.15rem; color: #e2e8f0; margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; } .campaign-phase__days { font-size: 0.8rem; color: #64748b; font-weight: 400; } .campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } .campaign-post { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1rem; transition: border-color 0.15s; } .campaign-post:hover { border-color: #6366f1; } .campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .campaign-post__platform { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; } .campaign-post__meta { flex: 1; min-width: 0; } .campaign-post__meta strong { display: block; font-size: 0.8rem; color: #e2e8f0; text-transform: capitalize; } .campaign-post__date { font-size: 0.7rem; color: #64748b; } .campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } .campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; } .campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; } .campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; } .campaign-post__content { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; margin: 0 0 0.5rem; } .campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } .campaign-tag { font-size: 0.65rem; color: #7dd3fc; } .campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } .campaign-action-btn { padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; } .campaign-action-btn--primary { background: #6366f1; color: white; border: none; } .campaign-action-btn--primary:hover { background: #818cf8; } .campaign-action-btn--outline { background: transparent; color: #94a3b8; border: 1px solid #334155; } .campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } .campaign-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; } .campaign-modal-overlay[hidden] { display: none; } .campaign-modal { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem; } .campaign-modal__header { display: flex; align-items: center; justify-content: space-between; } .campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: #f1f5f9; } .campaign-modal__close { background: none; border: none; color: #64748b; font-size: 1.5rem; cursor: pointer; line-height: 1; padding: 0; } .campaign-modal__close:hover { color: #e2e8f0; } .campaign-modal__textarea { width: 100%; min-height: 200px; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical; line-height: 1.5; box-sizing: border-box; } .campaign-modal__textarea:focus { outline: none; border-color: #6366f1; } .campaign-modal__textarea::placeholder { color: #475569; } .campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; } .campaign-modal__select { flex: 1; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; } .campaign-modal__select:focus { outline: none; border-color: #6366f1; } `; routes.get("/campaign", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `Campaign — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: renderCampaignPage(space), styles: ``, })); }); // ── Thread Builder ── const THREAD_CSS = ` .thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } .thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } .thread-page__header h1 { margin: 0; font-size: 1.5rem; color: #f1f5f9; background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } .thread-btn--primary { background: #6366f1; color: white; } .thread-btn--primary:hover { background: #818cf8; } .thread-btn--outline { background: transparent; color: #94a3b8; border: 1px solid #334155; } .thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } .thread-btn--success { background: #10b981; color: white; } .thread-btn--success:hover { background: #34d399; } .thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } .thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } .thread-compose__textarea { width: 100%; min-height: 320px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; line-height: 1.6; box-sizing: border-box; } .thread-compose__textarea:focus { outline: none; border-color: #6366f1; } .thread-compose__textarea::placeholder { color: #475569; } .thread-compose__fields { display: flex; gap: 0.75rem; } .thread-compose__input { flex: 1; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; } .thread-compose__input:focus { outline: none; border-color: #6366f1; } .thread-compose__input::placeholder { color: #475569; } .thread-compose__title { width: 100%; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; } .thread-compose__title:focus { outline: none; border-color: #6366f1; } .thread-compose__title::placeholder { color: #475569; } .thread-drafts { grid-column: 1 / -1; } .thread-drafts__toggle { cursor: pointer; user-select: none; } .thread-drafts__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; margin-top: 0.75rem; } .thread-drafts__list[hidden] { display: none; } .thread-drafts__empty { color: #475569; font-size: 0.8rem; padding: 0.5rem 0; } .thread-draft-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: #1e293b; border: 1px solid #334155; border-radius: 8px; transition: border-color 0.15s; cursor: pointer; } .thread-draft-item:hover { border-color: #6366f1; } .thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } .thread-draft-item__info { flex: 1; min-width: 0; } .thread-draft-item__info strong { display: block; font-size: 0.8rem; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .thread-draft-item__info span { font-size: 0.7rem; color: #64748b; } .thread-draft-item__delete { background: none; border: none; color: #64748b; font-size: 1.2rem; cursor: pointer; padding: 0 4px; line-height: 1; flex-shrink: 0; } .thread-draft-item__delete:hover { color: #ef4444; } .thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid #334155; } .thread-image-preview[hidden] { display: none; } .thread-image-preview img { display: block; max-width: 200px; height: auto; } .thread-share-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; font-size: 0.8rem; color: #c4b5fd; } .thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } .thread-share-link button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 0.75rem; padding: 2px 6px; } .thread-share-link button:hover { color: #e2e8f0; } .thread-preview { display: flex; flex-direction: column; gap: 0; } .thread-preview__empty { color: #475569; text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } .tweet-card { position: relative; background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1rem; margin-bottom: 0; } .tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; } .tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .tweet-card__connector { position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; background: #334155; z-index: 1; } .tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .tweet-card__avatar { width: 40px; height: 40px; border-radius: 50%; background: #6366f1; display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 1rem; flex-shrink: 0; } .tweet-card__name { font-weight: 700; color: #f1f5f9; font-size: 0.9rem; } .tweet-card__handle { color: #64748b; font-size: 0.85rem; } .tweet-card__dot { color: #64748b; font-size: 0.85rem; } .tweet-card__time { color: #64748b; font-size: 0.85rem; } .tweet-card__content { color: #e2e8f0; font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } .tweet-card__footer { display: flex; align-items: center; justify-content: space-between; } .tweet-card__actions { display: flex; gap: 1.25rem; } .tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: #64748b; font-size: 0.8rem; cursor: default; } .tweet-card__action svg { width: 16px; height: 16px; } .tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #64748b; } .tweet-card__chars { font-variant-numeric: tabular-nums; } .tweet-card__chars--over { color: #ef4444; font-weight: 600; } .tweet-card__thread-num { color: #6366f1; font-weight: 600; } @media (max-width: 700px) { .thread-page { grid-template-columns: 1fr; } .thread-compose { position: static; } } `; function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string { const dataScript = threadData ? `` : ""; const basePath = `/${space}/rsocials/`; return ` ${dataScript}

Thread Builder

Your tweet thread preview will appear here
`; } // ── Thread permalink with OG tags ── routes.get("/thread/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); const thread = await loadThread(id); if (!thread) return c.text("Thread not found", 404); const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); const titleText = escapeHtml(`Thread by ${thread.handle}`); const origin = "https://rspace.online"; let ogHead = ` `; if (thread.imageUrl) { ogHead += ` `; } return c.html(renderShell({ title: `${thread.title || "Thread"} — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: renderThreadBuilderPage(space, thread), styles: ``, head: ogHead, })); }); routes.get("/thread", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `Thread Builder — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: renderThreadBuilderPage(space), styles: ``, })); }); // ── Campaigns redirect (plural → singular) ── routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo"; return c.redirect(`/${space}/rsocials/campaign`); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); if (view === "app") { return c.html(renderExternalAppShell({ title: `${space} — Postiz | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), appUrl: "https://social.jeffemmett.com", appName: "Postiz", theme: "dark", })); } const isDemo = space === "demo"; const body = isDemo ? renderDemoFeedHTML() : `

Community Feed

Social activity across your community

Loading feed…
`; return c.html( renderShell({ title: `${space} — Socials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body, styles: ``, }), ); }); export const socialsModule: RSpaceModule = { id: "rsocials", name: "rSocials", icon: "📢", description: "Federated social feed aggregator for communities", scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rsocials.online", landingPage: renderLanding, externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" }, feeds: [ { id: "social-feed", name: "Social Feed", kind: "data", description: "Community social timeline — posts, links, and activity from connected platforms", }, ], acceptsFeeds: ["data", "trust"], outputPaths: [ { path: "campaigns", name: "Campaigns", icon: "📢", description: "Social media campaigns" }, { path: "posts", name: "Posts", icon: "📱", description: "Social feed posts across platforms" }, ], };