rspace-online/modules/rsocials/mod.ts

642 lines
23 KiB
TypeScript

/**
* 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<SocialsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SocialsDoc>(), "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<void> {
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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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: `<folk-campaign-manager space="${escapeHtml(space)}"></folk-campaign-manager>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-manager.js"></script>`,
}));
});
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 = `
<meta property="og:title" content="${titleText}">
<meta property="og:description" content="${desc}">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${titleText}">
<meta name="twitter:description" content="${desc}">`;
if (thread.imageUrl) {
ogHead += `
<meta property="og:image" content="${origin}${thread.imageUrl}">
<meta name="twitter:image" content="${origin}${thread.imageUrl}">`;
}
// Hydrate thread data for the component
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
return c.html(renderShell({
title: `${thread.title || "Thread"} — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="readonly"></folk-thread-builder>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
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 = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
return c.html(renderShell({
title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="edit"></folk-thread-builder>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
}));
});
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: `<folk-thread-builder space="${escapeHtml(space)}" mode="new"></folk-thread-builder>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
}));
});
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: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js"></script>`,
}));
});
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) => `
<article class="rsocials-item">
<div class="rsocials-item-header">
<div class="rsocials-avatar" style="background:${post.color}">${post.initial}</div>
<div class="rsocials-meta">
<strong>${post.username}</strong>
<time>${post.timeAgo}</time>
</div>
</div>
<p class="rsocials-item-content">${post.content}</p>
<div class="rsocials-item-actions">
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> ${post.likes}</span>
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${post.replies}</span>
</div>
</article>`,
).join("\n");
return `
<div class="rsocials-app rsocials-demo">
<div class="rsocials-header">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
<a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a>
</div>
<p class="rsocials-subtitle">A preview of your community's social timeline</p>
</div>
<div class="rsocials-feed">${cards}</div>
<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>
</div>`;
}
// ── Main 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",
}));
}
if (view === "feed") {
const isDemo = space === "demo";
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
const styles = isDemo
? `<link rel="stylesheet" href="/modules/rsocials/socials.css">`
: `<style>${RICH_LANDING_CSS}</style>`;
return c.html(renderShell({
title: `${space} — Socials Feed | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body,
styles,
}));
}
if (view === "landing") {
return c.html(renderShell({
title: `${space} — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderLanding(),
styles: `<style>${RICH_LANDING_CSS}</style>`,
}));
}
// Default: canvas view
return c.html(renderShell({
title: `${space} — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
body: `<folk-socials-canvas space="${escapeHtml(space)}"></folk-socials-canvas>`,
scripts: `<script type="module" src="/modules/rsocials/folk-socials-canvas.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials-canvas.css">`,
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://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" },
],
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.",
},
],
};