/** * 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, buildDemoCampaignFlow } 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 = {}; d.campaignFlows = {}; d.activeFlowId = ''; }); _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 campaign flow if empty if (Object.keys(doc.campaignFlows || {}).length === 0) { const docId = socialsDocId(space); const flow = buildDemoCampaignFlow(); _syncServer.changeDoc(docId, "seed campaign flow", (d) => { if (!d.campaignFlows) d.campaignFlows = {} as any; d.campaignFlows[flow.id] = flow; d.activeFlowId = flow.id; }); } // 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, 300); const prompt = `Abstract artistic illustration evoking the mood and themes of: "${summary}". Style: cinematic dark atmosphere with deep navy and charcoal tones, luminous flowing gradients of electric blue, violet, and teal, organic abstract shapes and flowing forms, subtle geometric patterns, ethereal light rays and bokeh, no text, no words, no letters, no typography, no UI elements. Pure visual art — evocative, atmospheric, and beautiful. Landscape 4:3.`; let cdnUrl: string | null; try { cdnUrl = await generateImageFromPrompt(prompt); } catch (e: any) { console.error("[rSocials] Thread image generation error:", e.message); return c.json({ error: "Image generation failed: " + (e.message || "unknown error") }, 502); } if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); const filename = `thread-${id}.png`; let imageUrl: string | null; try { imageUrl = await downloadAndSaveImage(cdnUrl, filename); } catch (e: any) { console.error("[rSocials] Image download error:", e.message); return c.json({ error: "Failed to download image: " + (e.message || "unknown error") }, 502); } 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}`; if (thread.imageUrl) 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, 300); const prompt = `Abstract artistic illustration inspired by the theme: "${tweetText}". Style: moody cinematic lighting on dark background, luminous abstract forms with flowing gradients of electric blue, violet, and warm amber, organic shapes suggesting the concept without being literal, subtle particle effects and light diffusion, no text, no words, no letters, no numbers, no typography whatsoever. Pure atmospheric visual art — stylistic, evocative, gallery-quality. Landscape 4:3.`; let cdnUrl: string | null; try { cdnUrl = await generateImageFromPrompt(prompt); } catch (e: any) { console.error("[rSocials] Tweet image generation error:", e.message); return c.json({ error: "Image generation failed: " + (e.message || "unknown error") }, 502); } 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); let imageUrl: string | null; try { imageUrl = await downloadAndSaveImage(cdnUrl, filename); } catch (e: any) { console.error("[rSocials] Image download error:", e.message); return c.json({ error: "Failed to download image: " + (e.message || "unknown error") }, 502); } 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 scheduler — embedded via iframe ── // Postiz runs at demo.rsocials.online (Traefik priority 130 > rSpace's 120). // The /scheduler route renders a full-page iframe shell. const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online"; routes.get("/scheduler", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderExternalAppShell({ title: `Post Scheduler — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", appName: "Postiz", appUrl: POSTIZ_URL, })); }); 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: campaign planner canvas ── 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: POSTIZ_URL, 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.", }, ], };