323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
|
* Socials module — federated social feed aggregator.
|
|
*
|
|
* Aggregates and displays social media activity across community members.
|
|
* Supports ActivityPub, RSS, and manual link sharing.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { renderLanding } from "./landing";
|
|
|
|
const routes = new Hono();
|
|
|
|
// ── 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,
|
|
});
|
|
});
|
|
|
|
// ── 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) => `
|
|
<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">
|
|
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
|
|
<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>`;
|
|
}
|
|
|
|
// ── Page route ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const isDemo = space === "demo";
|
|
|
|
const body = isDemo
|
|
? renderDemoFeedHTML()
|
|
: `
|
|
<div class="rsocials-app" data-space="${space}">
|
|
<div class="rsocials-header">
|
|
<h2>Community Feed</h2>
|
|
<p class="rsocials-subtitle">Social activity across your community</p>
|
|
</div>
|
|
<div id="rsocials-feed" class="rsocials-feed">
|
|
<div class="rsocials-loading">Loading feed…</div>
|
|
</div>
|
|
</div>
|
|
<script type="module">
|
|
const space = document.querySelector('.rsocials-app')?.dataset.space || 'demo';
|
|
const feedEl = document.getElementById('rsocials-feed');
|
|
|
|
try {
|
|
const res = await fetch('api/feed');
|
|
const data = await res.json();
|
|
|
|
if (!data.items?.length) {
|
|
feedEl.innerHTML = '<p class="rsocials-empty">No posts yet. Share something with your community!</p>';
|
|
return;
|
|
}
|
|
|
|
feedEl.innerHTML = data.items.map(item => {
|
|
const time = new Date(item.timestamp);
|
|
const ago = Math.round((Date.now() - time.getTime()) / 60000);
|
|
const timeStr = ago < 60 ? ago + 'm ago' : Math.round(ago / 60) + 'h ago';
|
|
const sourceTag = '<span class="rsocials-source">' + item.source + '</span>';
|
|
|
|
return '<article class="rsocials-item">' +
|
|
'<div class="rsocials-item-header">' +
|
|
'<strong>' + item.author + '</strong> ' + sourceTag +
|
|
'<time>' + timeStr + '</time>' +
|
|
'</div>' +
|
|
'<p class="rsocials-item-content">' + item.content + '</p>' +
|
|
(item.url ? '<a class="rsocials-item-link" href="' + item.url + '" target="_blank" rel="noopener">' + item.url + '</a>' : '') +
|
|
'<div class="rsocials-item-actions">' +
|
|
'<span>♥ ' + (item.likes || 0) + '</span>' +
|
|
'<span>💬 ' + (item.replies || 0) + '</span>' +
|
|
'</div>' +
|
|
'</article>';
|
|
}).join('');
|
|
|
|
if (data.demo) {
|
|
feedEl.insertAdjacentHTML('beforeend',
|
|
'<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>'
|
|
);
|
|
}
|
|
} catch(e) {
|
|
feedEl.innerHTML = '<p class="rsocials-empty">Failed to load feed.</p>';
|
|
}
|
|
</script>`;
|
|
|
|
return c.html(
|
|
renderShell({
|
|
title: `${space} — Socials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body,
|
|
styles: `<style>
|
|
.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
|
.rsocials-header { margin-bottom: 1.5rem; }
|
|
.rsocials-header h2 {
|
|
font-size: 1.5rem; margin: 0 0 0.25rem; display: flex; align-items: center; gap: 0.75rem;
|
|
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
}
|
|
.rsocials-demo-badge {
|
|
font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em;
|
|
background: #6366f1; color: white;
|
|
-webkit-text-fill-color: white;
|
|
padding: 2px 8px; border-radius: 4px;
|
|
text-transform: uppercase; line-height: 1.6;
|
|
}
|
|
.rsocials-subtitle { color: #64748b; font-size: 0.85rem; margin: 0; }
|
|
.rsocials-feed { display: flex; flex-direction: column; gap: 1px; }
|
|
.rsocials-loading { color: #64748b; padding: 2rem 0; text-align: center; }
|
|
.rsocials-empty { color: #64748b; padding: 2rem 0; text-align: center; }
|
|
.rsocials-item {
|
|
padding: 1rem; border-radius: 8px;
|
|
background: #1e293b; border: 1px solid rgba(255,255,255,0.06);
|
|
margin-bottom: 0.5rem;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.rsocials-item:hover { border-color: rgba(99,102,241,0.3); }
|
|
.rsocials-item-header {
|
|
display: flex; align-items: center; gap: 0.75rem;
|
|
font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.5rem;
|
|
}
|
|
.rsocials-avatar {
|
|
width: 36px; height: 36px; border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: white; font-weight: 700; font-size: 0.85rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.rsocials-meta {
|
|
display: flex; flex-direction: column; gap: 1px;
|
|
}
|
|
.rsocials-meta strong { color: #e2e8f0; font-size: 0.9rem; }
|
|
.rsocials-meta time { font-size: 0.75rem; color: #64748b; }
|
|
.rsocials-item-header strong { color: #e2e8f0; }
|
|
.rsocials-item-header time { margin-left: auto; font-size: 0.75rem; }
|
|
.rsocials-source {
|
|
font-size: 0.65rem; padding: 1px 6px; border-radius: 4px;
|
|
background: rgba(124,58,237,0.15); color: #c4b5fd;
|
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
}
|
|
.rsocials-item-content { margin: 0 0 0.75rem; color: #cbd5e1; line-height: 1.6; font-size: 0.9rem; }
|
|
.rsocials-item-link {
|
|
display: block; font-size: 0.8rem; color: #7dd3fc;
|
|
text-decoration: none; margin-bottom: 0.5rem; word-break: break-all;
|
|
}
|
|
.rsocials-item-link:hover { text-decoration: underline; }
|
|
.rsocials-item-actions {
|
|
display: flex; gap: 1rem; font-size: 0.8rem; color: #64748b;
|
|
}
|
|
.rsocials-action {
|
|
display: flex; align-items: center; gap: 0.35rem;
|
|
cursor: default;
|
|
}
|
|
.rsocials-action svg { opacity: 0.7; }
|
|
.rsocials-demo-notice {
|
|
text-align: center; font-size: 0.75rem; color: #475569;
|
|
padding: 1rem 0; border-top: 1px solid rgba(255,255,255,0.05); margin-top: 0.5rem;
|
|
}
|
|
</style>`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
export const socialsModule: RSpaceModule = {
|
|
id: "rsocials",
|
|
name: "rSocials",
|
|
icon: "📢",
|
|
description: "Federated social feed aggregator for communities",
|
|
routes,
|
|
standaloneDomain: "rsocials.online",
|
|
landingPage: renderLanding,
|
|
feeds: [
|
|
{
|
|
id: "social-feed",
|
|
name: "Social Feed",
|
|
kind: "data",
|
|
description: "Community social timeline — posts, links, and activity from connected platforms",
|
|
},
|
|
],
|
|
acceptsFeeds: ["data", "trust"],
|
|
};
|