/** * Socials module — federated social feed aggregator. * * Slim mod.ts: Automerge doc management, image API routes, * page routes (injecting web components), seed template, module export. * * All UI moved to web components in components/. * Thread/campaign CRUD handled by Automerge (no REST CRUD). * File-based threads migrated to Automerge on first access. */ import { resolve } from "node:path"; import { readdir, readFile } from "node:fs/promises"; import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell, escapeHtml, RICH_LANDING_CSS } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; import { MYCOFI_CAMPAIGN } from "./campaign-data"; import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas"; import { generateImageFromPrompt, downloadAndSaveImage, validateImageFile, safeExtension, saveUploadedFile, deleteImageFile, deleteOldImage, } from "./lib/image-gen"; import { DEMO_FEED } from "./lib/types"; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Automerge doc management ── function ensureDoc(space: string): SocialsDoc { const docId = socialsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), "init", (d) => { const init = socialsSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.threads = {}; d.campaigns = {}; }); _syncServer!.setDoc(docId, doc); } return doc; } function getThreadFromDoc(space: string, id: string): ThreadData | undefined { const doc = ensureDoc(space); return doc.threads?.[id]; } // ── Migration: file-based threads → Automerge ── async function migrateFileThreadsToAutomerge(space: string): Promise { const doc = ensureDoc(space); if (Object.keys(doc.threads || {}).length > 0) return; // Already has threads const threadsDir = resolve(process.env.FILES_DIR || "./data/files", "threads"); let files: string[]; try { files = await readdir(threadsDir); } catch { return; // No threads directory } let count = 0; const docId = socialsDocId(space); for (const f of files) { if (!f.endsWith(".json")) continue; try { const raw = await readFile(resolve(threadsDir, f), "utf-8"); const thread: ThreadData = JSON.parse(raw); _syncServer!.changeDoc(docId, `migrate thread ${thread.id}`, (d) => { if (!d.threads) d.threads = {} as any; d.threads[thread.id] = thread; }); count++; } catch { /* skip corrupt files */ } } if (count > 0) { console.log(`[rSocials] Migrated ${count} file-based threads to Automerge for space "${space}"`); } } // ── Seed template ── function seedTemplateSocials(space: string): void { if (!_syncServer) return; const doc = ensureDoc(space); // Seed MYCOFI_CAMPAIGN if no campaigns exist if (Object.keys(doc.campaigns || {}).length === 0) { const docId = socialsDocId(space); const now = Date.now(); _syncServer.changeDoc(docId, "seed campaign", (d) => { if (!d.campaigns) d.campaigns = {} as any; d.campaigns[MYCOFI_CAMPAIGN.id] = { ...MYCOFI_CAMPAIGN, createdAt: now, updatedAt: now, }; }); } // Seed a sample thread if empty if (Object.keys(doc.threads || {}).length === 0) { const docId = socialsDocId(space); const now = Date.now(); const threadId = `t-${now}-seed`; _syncServer.changeDoc(docId, "seed thread", (d) => { if (!d.threads) d.threads = {} as any; d.threads[threadId] = { id: threadId, name: "rSocials", handle: "@rsocials", title: "Welcome to Thread Builder", tweets: [ "Welcome to the rSocials Thread Builder! Write your thread content here, separated by --- between tweets.", "Each section becomes a separate tweet card with live character counts and thread numbering.", "When you're ready, export to Twitter, Bluesky, Mastodon, or LinkedIn. All locally stored, no third-party data mining.", ], createdAt: now, updatedAt: now, }; }); console.log(`[rSocials] Template seeded for "${space}": campaign + sample thread`); } } // ── API: Health & Info ── routes.get("/api/health", (c) => c.json({ ok: true, module: "rsocials" })); routes.get("/api/info", (c) => c.json({ module: "rsocials", description: "Federated social feed aggregator for communities", features: ["ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline"], }), ); // ── API: Demo feed ── routes.get("/api/feed", (c) => 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, }), ); // ── Image API routes (server-side, need filesystem + FAL_KEY) ── routes.post("/api/threads/:id/image", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); 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 cdnUrl = await generateImageFromPrompt(prompt); if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); const filename = `thread-${id}.png`; const imageUrl = await downloadAndSaveImage(cdnUrl, filename); if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); // Update Automerge doc with image URL const docId = socialsDocId(space); _syncServer!.changeDoc(docId, "set thread image", (d) => { if (d.threads?.[id]) { d.threads[id].imageUrl = imageUrl; d.threads[id].updatedAt = Date.now(); } }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/upload-image", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); const err = validateImageFile(file); if (err) return c.json({ error: err }, 400); const ext = safeExtension(file.name); const filename = `thread-${id}.${ext}`; await deleteOldImage(thread.imageUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(buffer, filename); const docId = socialsDocId(space); _syncServer!.changeDoc(docId, "upload thread image", (d) => { if (d.threads?.[id]) { d.threads[id].imageUrl = imageUrl; d.threads[id].updatedAt = Date.now(); } }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); const err = validateImageFile(file); if (err) return c.json({ error: err }, 400); const ext = safeExtension(file.name); const filename = `thread-${id}-tweet-${index}.${ext}`; const oldUrl = thread.tweetImages?.[index]; if (oldUrl) await deleteOldImage(oldUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(buffer, filename); const docId = socialsDocId(space); _syncServer!.changeDoc(docId, "upload tweet image", (d) => { if (d.threads?.[id]) { if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; d.threads[id].tweetImages![index] = imageUrl; d.threads[id].updatedAt = Date.now(); } }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/tweet/:index/image", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); const tweetIndex = parseInt(index, 10); if (tweetIndex < 0 || tweetIndex >= thread.tweets.length) { return c.json({ error: "Tweet index out of range" }, 400); } if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const tweetText = thread.tweets[tweetIndex].substring(0, 200); const prompt = `Social media post image about: ${tweetText}. Dark themed, modern, minimal style with abstract shapes.`; const cdnUrl = await generateImageFromPrompt(prompt); if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); const filename = `thread-${id}-tweet-${index}.png`; const oldUrl = thread.tweetImages?.[index]; if (oldUrl) await deleteOldImage(oldUrl, filename); const imageUrl = await downloadAndSaveImage(cdnUrl, filename); if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); const docId = socialsDocId(space); _syncServer!.changeDoc(docId, "generate tweet image", (d) => { if (d.threads?.[id]) { if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; d.threads[id].tweetImages![index] = imageUrl; d.threads[id].updatedAt = Date.now(); } }); return c.json({ imageUrl }); }); routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread.tweetImages?.[index]) return c.json({ ok: true }); await deleteImageFile(thread.tweetImages[index]); const docId = socialsDocId(space); _syncServer!.changeDoc(docId, "remove tweet image", (d) => { if (d.threads?.[id]?.tweetImages?.[index]) { delete d.threads[id].tweetImages![index]; if (Object.keys(d.threads[id].tweetImages || {}).length === 0) { delete d.threads[id].tweetImages; } d.threads[id].updatedAt = Date.now(); } }); return c.json({ ok: true }); }); routes.delete("/api/threads/:id/images", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ ok: true }); // Thread already gone // Clean up header image if (thread.imageUrl) await deleteImageFile(thread.imageUrl); // Clean up per-tweet images if (thread.tweetImages) { for (const url of Object.values(thread.tweetImages)) { await deleteImageFile(url); } } return c.json({ ok: true }); }); // ── Page routes (inject web components) ── 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: ``, styles: ``, scripts: ``, })); }); 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 = getThreadFromDoc(space, id); if (!thread) return c.text("Thread not found", 404); // OG tags for social crawlers (SSR) 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 += ` `; } // Hydrate thread data for the component const dataScript = ``; return c.html(renderShell({ title: `${thread.title || "Thread"} — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: `${dataScript}`, styles: ``, scripts: ``, head: ogHead, })); }); routes.get("/thread/:id/edit", 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 = getThreadFromDoc(space, id); if (!thread) return c.text("Thread not found", 404); const dataScript = ``; return c.html(renderShell({ title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: `${dataScript}`, styles: ``, scripts: ``, })); }); 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: ``, styles: ``, scripts: ``, })); }); routes.get("/threads", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `Threads — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, styles: ``, scripts: ``, })); }); routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo"; return c.redirect(`/${space}/rsocials/campaign`); }); // ── Demo feed rendering (server-rendered, no web component needed) ── 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.

`; } // ── Path-based sub-routes ── // ── Postiz reverse proxy at /scheduler/* ── // Proxies to the Postiz container on the Docker network, avoiding // the Traefik priority conflict with rSpace's rsocials.online catch-all. const POSTIZ_UPSTREAM = process.env.POSTIZ_URL || "http://postiz:5000"; routes.all("/scheduler/*", async (c) => { const subPath = c.req.path.replace(/^\/[^/]+\/rsocials\/scheduler/, "") || "/"; const upstreamUrl = `${POSTIZ_UPSTREAM}${subPath}`; const url = new URL(upstreamUrl); // Preserve query string const reqUrl = new URL(c.req.url); url.search = reqUrl.search; try { const headers = new Headers(c.req.raw.headers); headers.delete("host"); const resp = await fetch(url.toString(), { method: c.req.method, headers, body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, redirect: "manual", }); // Rewrite Location headers to stay within /scheduler/ const respHeaders = new Headers(resp.headers); const location = respHeaders.get("location"); if (location) { try { const locUrl = new URL(location, upstreamUrl); if (locUrl.origin === new URL(POSTIZ_UPSTREAM).origin) { const space = c.req.param("space") || "demo"; respHeaders.set("location", `/${space}/rsocials/scheduler${locUrl.pathname}${locUrl.search}`); } } catch { /* keep original */ } } return new Response(resp.body, { status: resp.status, headers: respHeaders, }); } catch { return c.text("Postiz scheduler unavailable", 502); } }); routes.get("/scheduler", async (c) => { const space = c.req.param("space") || "demo"; const upstreamUrl = `${POSTIZ_UPSTREAM}/`; try { const headers = new Headers(c.req.raw.headers); headers.delete("host"); const reqUrl = new URL(c.req.url); const resp = await fetch(`${upstreamUrl}${reqUrl.search}`, { method: "GET", headers, redirect: "manual", }); const respHeaders = new Headers(resp.headers); const location = respHeaders.get("location"); if (location) { try { const locUrl = new URL(location, upstreamUrl); if (locUrl.origin === new URL(POSTIZ_UPSTREAM).origin) { respHeaders.set("location", `/${space}/rsocials/scheduler${locUrl.pathname}${locUrl.search}`); } } catch { /* keep original */ } } return new Response(resp.body, { status: resp.status, headers: respHeaders, }); } catch { return c.text("Postiz scheduler unavailable", 502); } }); routes.get("/feed", (c) => { const space = c.req.param("space") || "demo"; const isDemo = space === "demo"; const body = isDemo ? renderDemoFeedHTML() : renderLanding(); const styles = isDemo ? `` : ``; return c.html(renderShell({ title: `${space} — Socials Feed | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body, styles, })); }); routes.get("/landing", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: renderLanding(), styles: ``, })); }); // ── Default: canvas view ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), body: ``, scripts: ``, styles: ``, theme: "dark", })); }); // ── Module export ── export const socialsModule: RSpaceModule = { id: "rsocials", name: "rSocials", icon: "📢", description: "Federated social feed aggregator for communities", scoping: { defaultScope: "global", userConfigurable: true }, docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }], routes, publicWrite: true, standaloneDomain: "rsocials.online", landingPage: renderLanding, seedTemplate: seedTemplateSocials, async onInit(ctx) { _syncServer = ctx.syncServer; // Run migration for any existing file-based threads try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ } }, externalApp: { url: "https://demo.rsocials.online", 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" }, ], subPageInfos: [ { path: "thread", title: "Thread Builder", icon: "🧵", tagline: "rSocials Tool", description: "Compose, preview, and schedule tweet threads with a live card-by-card preview. Save drafts, generate share images, and publish when ready.", features: [ { icon: "✍️", title: "Live Preview", text: "See your thread as tweet cards in real time as you type, with character counts and thread numbering." }, { icon: "💾", title: "Save & Edit Drafts", text: "Save thread drafts to your space, revisit and refine them before publishing." }, { icon: "🖼️", title: "Share Images", text: "Auto-generate a branded share image of your thread for cross-posting." }, ], }, { path: "campaign", title: "Campaign Manager", icon: "📢", tagline: "rSocials Tool", description: "Plan and track multi-platform social media campaigns with scheduling, analytics, and team collaboration.", features: [ { icon: "📅", title: "Schedule Posts", text: "Queue posts across platforms with a visual calendar timeline." }, { icon: "📊", title: "Track Performance", text: "Monitor engagement metrics and campaign reach in one dashboard." }, { icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." }, ], }, { path: "threads", title: "Thread Gallery", icon: "📋", tagline: "rSocials Tool", description: "Browse all saved thread drafts in your community. Find inspiration, remix threads, or pick up where you left off.", }, ], };