feat(swarm): Implement decentralized node discovery and content federation system
- Add swarm node registry with discovery, gossip, and timeline synchronization - Implement database schema for swarm nodes, seeds, and sync logs with proper indexing - Create swarm API endpoints for node discovery, gossip protocol, timeline sharing, and announcements - Add content moderation features with NSFW flagging and muted nodes support - Implement user settings pages for content filtering and node moderation - Add background scheduler for automated swarm synchronization and node health checks - Create .well-known endpoint for swarm protocol discovery - Remove legacy bot-cron.ts in favor of integrated scheduler system - Add user account NSFW preferences and age verification tracking - Update database schema with swarm-related tables and user moderation fields - Enhance post and user components to support federated content display - Add instrumentation for monitoring swarm operations and node health
This commit is contained in:
-63
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bot Cron Job Script
|
|
||||||
*
|
|
||||||
* Run with PM2:
|
|
||||||
* pm2 start bot-cron.ts --name "bot-cron" --cron "* * * * *" --no-autorestart
|
|
||||||
*
|
|
||||||
* Or for continuous running with internal interval:
|
|
||||||
* pm2 start bot-cron.ts --name "bot-cron"
|
|
||||||
*/
|
|
||||||
|
|
||||||
const INTERVAL_MS = 60 * 1000; // 1 minute
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_NODE_DOMAIN
|
|
||||||
? `https://${process.env.NEXT_PUBLIC_NODE_DOMAIN}/api/cron/bots`
|
|
||||||
: 'http://localhost:3000/api/cron/bots';
|
|
||||||
const AUTH_SECRET = process.env.AUTH_SECRET || '';
|
|
||||||
|
|
||||||
async function runCron() {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
console.log(`[${timestamp}] Running bot cron job...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (AUTH_SECRET) {
|
|
||||||
headers['Authorization'] = `Bearer ${AUTH_SECRET}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(API_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log(`[${timestamp}] Cron completed:`, JSON.stringify(data, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(`[${timestamp}] Cron failed:`, data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${timestamp}] Cron error:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running with PM2 cron (single execution) or continuous mode
|
|
||||||
const isPM2Cron = process.env.PM2_CRON === 'true' || process.argv.includes('--once');
|
|
||||||
|
|
||||||
if (isPM2Cron) {
|
|
||||||
// Single execution mode (PM2 handles scheduling)
|
|
||||||
runCron().then(() => process.exit(0));
|
|
||||||
} else {
|
|
||||||
// Continuous mode with internal interval
|
|
||||||
console.log(`Bot cron started. Running every ${INTERVAL_MS / 1000} seconds.`);
|
|
||||||
console.log(`API URL: ${API_URL}`);
|
|
||||||
|
|
||||||
// Run immediately on start
|
|
||||||
runCron();
|
|
||||||
|
|
||||||
// Then run on interval
|
|
||||||
setInterval(runCron, INTERVAL_MS);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
CREATE TABLE "swarm_nodes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"domain" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"description" text,
|
||||||
|
"logo_url" text,
|
||||||
|
"public_key" text,
|
||||||
|
"software_version" text,
|
||||||
|
"user_count" integer,
|
||||||
|
"post_count" integer,
|
||||||
|
"discovered_via" text,
|
||||||
|
"discovered_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"last_seen_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"last_sync_at" timestamp,
|
||||||
|
"consecutive_failures" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"trust_score" integer DEFAULT 50 NOT NULL,
|
||||||
|
"capabilities" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "swarm_nodes_domain_unique" UNIQUE("domain")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "swarm_seeds" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"domain" text NOT NULL,
|
||||||
|
"priority" integer DEFAULT 100 NOT NULL,
|
||||||
|
"is_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"last_contact_at" timestamp,
|
||||||
|
"consecutive_failures" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "swarm_seeds_domain_unique" UNIQUE("domain")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "swarm_sync_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"remote_domain" text NOT NULL,
|
||||||
|
"direction" text NOT NULL,
|
||||||
|
"nodes_received" integer DEFAULT 0 NOT NULL,
|
||||||
|
"nodes_sent" integer DEFAULT 0 NOT NULL,
|
||||||
|
"handles_received" integer DEFAULT 0 NOT NULL,
|
||||||
|
"handles_sent" integer DEFAULT 0 NOT NULL,
|
||||||
|
"success" boolean NOT NULL,
|
||||||
|
"error_message" text,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_nodes_domain_idx" ON "swarm_nodes" USING btree ("domain");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_nodes_active_idx" ON "swarm_nodes" USING btree ("is_active");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_nodes_last_seen_idx" ON "swarm_nodes" USING btree ("last_seen_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_nodes_trust_idx" ON "swarm_nodes" USING btree ("trust_score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_seeds_enabled_idx" ON "swarm_seeds" USING btree ("is_enabled");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_seeds_priority_idx" ON "swarm_seeds" USING btree ("priority");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_sync_log_remote_idx" ON "swarm_sync_log" USING btree ("remote_domain");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_sync_log_created_idx" ON "swarm_sync_log" USING btree ("created_at");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "muted_nodes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"node_domain" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "nodes" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "posts" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "swarm_nodes" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "nsfw_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "age_verified_at" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "muted_nodes" ADD CONSTRAINT "muted_nodes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "muted_nodes_user_idx" ON "muted_nodes" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "muted_nodes_domain_idx" ON "muted_nodes" USING btree ("node_domain");--> statement-breakpoint
|
||||||
|
CREATE INDEX "blocks_blocked_user_idx" ON "blocks" USING btree ("blocked_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "mutes_muted_user_idx" ON "mutes" USING btree ("muted_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "posts_nsfw_idx" ON "posts" USING btree ("is_nsfw");--> statement-breakpoint
|
||||||
|
CREATE INDEX "swarm_nodes_nsfw_idx" ON "swarm_nodes" USING btree ("is_nsfw");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_nsfw_idx" ON "users" USING btree ("is_nsfw");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,20 @@
|
|||||||
"when": 1769367465905,
|
"when": 1769367465905,
|
||||||
"tag": "0002_add_logo_url",
|
"tag": "0002_add_logo_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769370833410,
|
||||||
|
"tag": "0003_small_reavers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769377790354,
|
||||||
|
"tag": "0004_luxuriant_lockheed",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Well-Known Synapsis Swarm Endpoint
|
||||||
|
*
|
||||||
|
* GET /.well-known/synapsis-swarm
|
||||||
|
*
|
||||||
|
* Returns information about this node and the swarm it knows about.
|
||||||
|
* This is a standardized discovery endpoint that other nodes can use
|
||||||
|
* to find and join the swarm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||||
|
import { getActiveSwarmNodes, getSwarmStats, getSeedNodes } from '@/lib/swarm/registry';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeNodes = searchParams.get('nodes') !== 'false';
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||||
|
|
||||||
|
const announcement = await buildAnnouncement();
|
||||||
|
const stats = await getSwarmStats();
|
||||||
|
const seeds = await getSeedNodes();
|
||||||
|
|
||||||
|
const response: {
|
||||||
|
// This node's info
|
||||||
|
node: {
|
||||||
|
domain: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
publicKey: string;
|
||||||
|
softwareVersion: string;
|
||||||
|
capabilities: string[];
|
||||||
|
};
|
||||||
|
// Swarm metadata
|
||||||
|
swarm: {
|
||||||
|
totalNodes: number;
|
||||||
|
activeNodes: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalPosts: number;
|
||||||
|
seeds: string[];
|
||||||
|
};
|
||||||
|
// Known nodes (optional)
|
||||||
|
nodes?: Awaited<ReturnType<typeof getActiveSwarmNodes>>;
|
||||||
|
} = {
|
||||||
|
node: {
|
||||||
|
domain: announcement.domain,
|
||||||
|
name: announcement.name,
|
||||||
|
description: announcement.description,
|
||||||
|
publicKey: announcement.publicKey,
|
||||||
|
softwareVersion: announcement.softwareVersion,
|
||||||
|
capabilities: announcement.capabilities,
|
||||||
|
},
|
||||||
|
swarm: {
|
||||||
|
...stats,
|
||||||
|
seeds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeNodes) {
|
||||||
|
response.nodes = await getActiveSwarmNodes(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Synapsis swarm well-known error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch swarm info' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
|||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { User, Post } from '@/lib/types';
|
import { User, Post } from '@/lib/types';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Rocket } from 'lucide-react';
|
import { Rocket, MoreHorizontal } from 'lucide-react';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ export default function ProfilePage() {
|
|||||||
});
|
});
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -173,6 +175,7 @@ export default function ProfilePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || !user || currentUser.handle === user.handle) {
|
if (!currentUser || !user || currentUser.handle === user.handle) {
|
||||||
setIsFollowing(false);
|
setIsFollowing(false);
|
||||||
|
setIsBlocked(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,11 @@ export default function ProfilePage() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setIsFollowing(!!data.following))
|
.then(data => setIsFollowing(!!data.following))
|
||||||
.catch(() => setIsFollowing(false));
|
.catch(() => setIsFollowing(false));
|
||||||
|
|
||||||
|
fetch(`/api/users/${handle}/block`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setIsBlocked(!!data.blocked))
|
||||||
|
.catch(() => setIsBlocked(false));
|
||||||
}, [currentUser, user, handle]);
|
}, [currentUser, user, handle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,6 +234,22 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlock = async () => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const method = isBlocked ? 'DELETE' : 'POST';
|
||||||
|
const res = await fetch(`/api/users/${handle}/block`, { method });
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setIsBlocked(!isBlocked);
|
||||||
|
if (!isBlocked) {
|
||||||
|
// If blocking, also unfollow
|
||||||
|
setIsFollowing(false);
|
||||||
|
}
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
if (!isOwnProfile) return;
|
if (!isOwnProfile) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -377,8 +401,10 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '12px' }}>
|
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
{!isOwnProfile && currentUser && (
|
{!isOwnProfile && currentUser && (
|
||||||
|
<>
|
||||||
|
{!isBlocked && (
|
||||||
<button
|
<button
|
||||||
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
|
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
|
||||||
onClick={handleFollow}
|
onClick={handleFollow}
|
||||||
@@ -386,6 +412,57 @@ export default function ProfilePage() {
|
|||||||
{isFollowing ? 'Following' : 'Follow'}
|
{isFollowing ? 'Following' : 'Follow'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
style={{ padding: '8px' }}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={20} />
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 99,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '100%',
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
minWidth: '160px',
|
||||||
|
zIndex: 100,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handleBlock}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: isBlocked ? 'var(--foreground)' : 'var(--error)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBlocked ? 'Unblock user' : 'Block user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<button className="btn" onClick={() => setIsEditing(!isEditing)}>
|
<button className="btn" onClick={() => setIsEditing(!isEditing)}>
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export default function PostDetailPage() {
|
|||||||
fetchPostDetail();
|
fetchPostDetail();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => {
|
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
|
||||||
const res = await fetch('/api/posts', {
|
const res = await fetch('/api/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }),
|
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function AdminPage() {
|
|||||||
bannerUrl: '',
|
bannerUrl: '',
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
accentColor: '#00D4AA',
|
accentColor: '#00D4AA',
|
||||||
|
isNsfw: false,
|
||||||
});
|
});
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||||
@@ -136,6 +137,7 @@ export default function AdminPage() {
|
|||||||
bannerUrl: data.bannerUrl || '',
|
bannerUrl: data.bannerUrl || '',
|
||||||
logoUrl: data.logoUrl || '',
|
logoUrl: data.logoUrl || '',
|
||||||
accentColor: data.accentColor || '#00D4AA',
|
accentColor: data.accentColor || '#00D4AA',
|
||||||
|
isNsfw: data.isNsfw || false,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// error
|
// error
|
||||||
@@ -682,6 +684,49 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: nodeSettings.isNsfw ? 'rgba(239, 68, 68, 0.1)' : 'var(--background-secondary)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: nodeSettings.isNsfw ? '1px solid var(--error)' : '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', display: 'block' }}>
|
||||||
|
NSFW Node
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--foreground-secondary)', margin: 0 }}>
|
||||||
|
{nodeSettings.isNsfw
|
||||||
|
? 'This node is marked as NSFW. All content will be hidden from users who haven\'t enabled NSFW viewing.'
|
||||||
|
: 'Enable this if your node primarily hosts adult or sensitive content. All posts from this node will be treated as NSFW across the swarm.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${nodeSettings.isNsfw ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{
|
||||||
|
background: nodeSettings.isNsfw ? 'var(--error)' : undefined,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!nodeSettings.isNsfw) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Are you sure you want to mark this node as NSFW?\n\n' +
|
||||||
|
'All content from this node will be hidden from users who haven\'t enabled NSFW viewing. ' +
|
||||||
|
'This affects the entire swarm.'
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
setNodeSettings({ ...nodeSettings, isNsfw: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNodeSettings({ ...nodeSettings, isNsfw: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nodeSettings.isNsfw ? 'Remove NSFW' : 'Mark as NSFW'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '8px' }}>
|
<div style={{ paddingTop: '8px' }}>
|
||||||
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
||||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
bannerUrl: data.bannerUrl,
|
bannerUrl: data.bannerUrl,
|
||||||
logoUrl: data.logoUrl,
|
logoUrl: data.logoUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
|
isNsfw: data.isNsfw ?? false,
|
||||||
}).returning();
|
}).returning();
|
||||||
} else {
|
} else {
|
||||||
[node] = await db.update(nodes)
|
[node] = await db.update(nodes)
|
||||||
@@ -36,6 +37,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
bannerUrl: data.bannerUrl,
|
bannerUrl: data.bannerUrl,
|
||||||
logoUrl: data.logoUrl,
|
logoUrl: data.logoUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
|
isNsfw: data.isNsfw ?? node.isNsfw,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(nodes.id, node.id))
|
.where(eq(nodes.id, node.id))
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Cron endpoint for swarm operations
|
||||||
|
*
|
||||||
|
* Called periodically to run gossip rounds and maintain swarm health
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { runGossipRound } from '@/lib/swarm/gossip';
|
||||||
|
import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||||
|
import { getSwarmStats } from '@/lib/swarm/registry';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Verify using AUTH_SECRET to prevent unauthorized access
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const authSecret = process.env.AUTH_SECRET;
|
||||||
|
|
||||||
|
if (authSecret && authHeader !== `Bearer ${authSecret}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get('action') || 'gossip';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'announce') {
|
||||||
|
// Announce to seed nodes (used on startup)
|
||||||
|
const result = await announceToSeeds();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
action: 'announce',
|
||||||
|
successful: result.successful,
|
||||||
|
failed: result.failed,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: run gossip round
|
||||||
|
const gossipResult = await runGossipRound();
|
||||||
|
const stats = await getSwarmStats();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
action: 'gossip',
|
||||||
|
gossip: gossipResult,
|
||||||
|
swarm: stats,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cron swarm processing error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process swarm', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-55
@@ -20,6 +20,7 @@ const createPostSchema = z.object({
|
|||||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||||
replyToId: z.string().uuid().optional(),
|
replyToId: z.string().uuid().optional(),
|
||||||
mediaIds: z.array(z.string().uuid()).max(4).optional(),
|
mediaIds: z.array(z.string().uuid()).max(4).optional(),
|
||||||
|
isNsfw: z.boolean().optional(),
|
||||||
linkPreview: z.object({
|
linkPreview: z.object({
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
@@ -45,6 +46,7 @@ export async function POST(request: Request) {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
content: data.content,
|
content: data.content,
|
||||||
replyToId: data.replyToId,
|
replyToId: data.replyToId,
|
||||||
|
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW
|
||||||
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
linkPreviewUrl: data.linkPreview?.url,
|
linkPreviewUrl: data.linkPreview?.url,
|
||||||
@@ -282,6 +284,7 @@ export async function GET(request: Request) {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
} else if (type === 'curated') {
|
} else if (type === 'curated') {
|
||||||
|
// Curated feed - swarm posts only (no fediverse)
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
try {
|
try {
|
||||||
const { getSession } = await import('@/lib/auth');
|
const { getSession } = await import('@/lib/auth');
|
||||||
@@ -291,48 +294,40 @@ export async function GET(request: Request) {
|
|||||||
viewer = null;
|
viewer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedLimit = Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP);
|
// Fetch swarm posts
|
||||||
const seedPosts = await db.query.posts.findMany({
|
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
||||||
where: baseFilter,
|
const swarmResult = await fetchSwarmTimeline(10, 30);
|
||||||
with: {
|
|
||||||
author: true,
|
// Transform swarm posts to match local post format
|
||||||
bot: true,
|
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||||
media: true,
|
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||||
replyTo: {
|
content: sp.content,
|
||||||
with: { author: true },
|
createdAt: new Date(sp.createdAt),
|
||||||
|
likesCount: sp.likeCount,
|
||||||
|
repostsCount: sp.repostCount,
|
||||||
|
repliesCount: sp.replyCount,
|
||||||
|
isSwarm: true,
|
||||||
|
nodeDomain: sp.nodeDomain,
|
||||||
|
author: {
|
||||||
|
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
|
||||||
|
handle: sp.author.handle,
|
||||||
|
displayName: sp.author.displayName,
|
||||||
|
avatarUrl: sp.author.avatarUrl,
|
||||||
|
isSwarm: true,
|
||||||
|
nodeDomain: sp.nodeDomain,
|
||||||
},
|
},
|
||||||
},
|
media: sp.mediaUrls?.map((url, idx) => ({
|
||||||
orderBy: [desc(posts.createdAt)],
|
id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
|
||||||
limit: seedLimit,
|
url,
|
||||||
});
|
altText: null,
|
||||||
|
})) || [],
|
||||||
|
replyTo: null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Also get cached remote posts for the curated feed
|
|
||||||
const remotePostsData = await db.query.remotePosts.findMany({
|
|
||||||
orderBy: [desc(remotePosts.publishedAt)],
|
|
||||||
limit: Math.floor(seedLimit / 2),
|
|
||||||
});
|
|
||||||
const transformedRemote = transformRemotePosts(remotePostsData);
|
|
||||||
|
|
||||||
// Combine local and remote for ranking
|
|
||||||
const allSeedPosts = [...seedPosts, ...transformedRemote];
|
|
||||||
|
|
||||||
let followingIds = new Set<string>();
|
|
||||||
let followingRemoteHandles = new Set<string>();
|
|
||||||
let mutedIds = new Set<string>();
|
let mutedIds = new Set<string>();
|
||||||
let blockedIds = new Set<string>();
|
let blockedIds = new Set<string>();
|
||||||
|
|
||||||
if (viewer) {
|
if (viewer) {
|
||||||
const followRows = await db.select({ followingId: follows.followingId })
|
|
||||||
.from(follows)
|
|
||||||
.where(eq(follows.followerId, viewer.id));
|
|
||||||
followingIds = new Set(followRows.map(row => row.followingId));
|
|
||||||
|
|
||||||
// Also get remote follows for boost
|
|
||||||
const remoteFollowRows = await db.query.remoteFollows.findMany({
|
|
||||||
where: eq(remoteFollows.followerId, viewer.id),
|
|
||||||
});
|
|
||||||
followingRemoteHandles = new Set(remoteFollowRows.map(row => row.targetHandle));
|
|
||||||
|
|
||||||
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
||||||
.from(mutes)
|
.from(mutes)
|
||||||
.where(eq(mutes.userId, viewer.id));
|
.where(eq(mutes.userId, viewer.id));
|
||||||
@@ -345,7 +340,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rankedPosts = allSeedPosts
|
const rankedPosts = swarmPosts
|
||||||
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
||||||
.map((post: any) => {
|
.map((post: any) => {
|
||||||
const createdAt = new Date(post.createdAt).getTime();
|
const createdAt = new Date(post.createdAt).getTime();
|
||||||
@@ -354,23 +349,10 @@ export async function GET(request: Request) {
|
|||||||
const engagementScore = Math.log1p(Math.max(0, engagement));
|
const engagementScore = Math.log1p(Math.max(0, engagement));
|
||||||
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
||||||
|
|
||||||
const isRemote = post.isRemote === true;
|
const score = engagementScore * 1.4 + recencyScore * 1.1;
|
||||||
const followBoost = viewer && (
|
|
||||||
followingIds.has(post.author.id) ||
|
|
||||||
(isRemote && followingRemoteHandles.has(post.author.handle))
|
|
||||||
) ? 0.9 : 0;
|
|
||||||
const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0;
|
|
||||||
const federatedBoost = isRemote ? 0.3 : 0; // Small boost for federated content diversity
|
|
||||||
|
|
||||||
const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost + federatedBoost;
|
|
||||||
|
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (isRemote) {
|
reasons.push(`From ${post.nodeDomain}`);
|
||||||
reasons.push(`From the fediverse`);
|
|
||||||
}
|
|
||||||
if (followBoost > 0) {
|
|
||||||
reasons.push(`You follow @${post.author.handle}`);
|
|
||||||
}
|
|
||||||
if (engagement >= 5) {
|
if (engagement >= 5) {
|
||||||
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
|
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
|
||||||
} else if ((post.repliesCount || 0) > 0) {
|
} else if ((post.repliesCount || 0) > 0) {
|
||||||
@@ -380,10 +362,8 @@ export async function GET(request: Request) {
|
|||||||
reasons.push('Posted recently');
|
reasons.push('Posted recently');
|
||||||
} else if (ageHours <= 24) {
|
} else if (ageHours <= 24) {
|
||||||
reasons.push('Posted today');
|
reasons.push('Posted today');
|
||||||
} else if (ageHours <= CURATION_WINDOW_HOURS) {
|
|
||||||
reasons.push('Recent');
|
|
||||||
}
|
}
|
||||||
if (reasons.length === 0) {
|
if (reasons.length === 1) {
|
||||||
reasons.push('New post');
|
reasons.push('New post');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Posts Endpoint
|
||||||
|
*
|
||||||
|
* GET: Returns aggregated posts from across the swarm
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||||
|
|
||||||
|
// Simple in-memory cache for swarm timeline
|
||||||
|
let cachedTimeline: Awaited<ReturnType<typeof fetchSwarmTimeline>> | null = null;
|
||||||
|
let cacheTimestamp = 0;
|
||||||
|
const CACHE_TTL_MS = 60 * 1000; // 1 minute cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/posts/swarm
|
||||||
|
*
|
||||||
|
* Returns aggregated posts from across the swarm network.
|
||||||
|
* Results are cached for 1 minute to reduce load on other nodes.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const refresh = searchParams.get('refresh') === 'true';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached data if fresh
|
||||||
|
if (!refresh && cachedTimeline && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: cachedTimeline.posts,
|
||||||
|
sources: cachedTimeline.sources,
|
||||||
|
cached: true,
|
||||||
|
fetchedAt: cachedTimeline.fetchedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
const timeline = await fetchSwarmTimeline(10, 15);
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cachedTimeline = timeline;
|
||||||
|
cacheTimestamp = now;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: timeline.posts,
|
||||||
|
sources: timeline.sources,
|
||||||
|
cached: false,
|
||||||
|
fetchedAt: timeline.fetchedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm posts error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch swarm posts' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Account NSFW Setting API
|
||||||
|
*
|
||||||
|
* POST: Mark/unmark your account as NSFW (content creator setting)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
isNsfw: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/settings/account-nsfw
|
||||||
|
*
|
||||||
|
* Mark your account as producing NSFW content.
|
||||||
|
* All your posts will be treated as NSFW.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const body = await request.json();
|
||||||
|
const { isNsfw } = updateSchema.parse(body);
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({
|
||||||
|
isNsfw,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
isNsfw,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { blocks, users } from '@/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
// GET - List blocked users
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
|
||||||
|
const blocked = await db.query.blocks.findMany({
|
||||||
|
where: eq(blocks.userId, currentUser.id),
|
||||||
|
with: {
|
||||||
|
blockedUser: true,
|
||||||
|
},
|
||||||
|
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
blockedUsers: blocked.map(b => ({
|
||||||
|
id: b.blockedUser.id,
|
||||||
|
handle: b.blockedUser.handle,
|
||||||
|
displayName: b.blockedUser.displayName,
|
||||||
|
avatarUrl: b.blockedUser.avatarUrl,
|
||||||
|
blockedAt: b.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Get blocked users error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get blocked users' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Unblock a user by ID
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(blocks).where(
|
||||||
|
and(
|
||||||
|
eq(blocks.userId, currentUser.id),
|
||||||
|
eq(blocks.blockedUserId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ unblocked: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Unblock user error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { mutedNodes } from '@/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
// GET - List muted nodes
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
|
||||||
|
const muted = await db.query.mutedNodes.findMany({
|
||||||
|
where: eq(mutedNodes.userId, currentUser.id),
|
||||||
|
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
mutedNodes: muted.map(m => ({
|
||||||
|
domain: m.nodeDomain,
|
||||||
|
mutedAt: m.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Get muted nodes error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get muted nodes' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Mute a node
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { domain } = await req.json();
|
||||||
|
|
||||||
|
if (!domain || typeof domain !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDomain = domain.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Check if already muted
|
||||||
|
const existing = await db.query.mutedNodes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(mutedNodes.userId, currentUser.id),
|
||||||
|
eq(mutedNodes.nodeDomain, normalizedDomain)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ muted: true, domain: normalizedDomain });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(mutedNodes).values({
|
||||||
|
userId: currentUser.id,
|
||||||
|
nodeDomain: normalizedDomain,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ muted: true, domain: normalizedDomain });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Mute node error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to mute node' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Unmute a node
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const domain = searchParams.get('domain');
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDomain = domain.toLowerCase().trim();
|
||||||
|
|
||||||
|
await db.delete(mutedNodes).where(
|
||||||
|
and(
|
||||||
|
eq(mutedNodes.userId, currentUser.id),
|
||||||
|
eq(mutedNodes.nodeDomain, normalizedDomain)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ muted: false, domain: normalizedDomain });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Unmute node error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to unmute node' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* NSFW Settings API
|
||||||
|
*
|
||||||
|
* GET: Get current user's NSFW settings
|
||||||
|
* POST: Update NSFW settings (requires age verification for enabling)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
nsfwEnabled: z.boolean(),
|
||||||
|
confirmAge: z.boolean().optional(), // Must be true when enabling NSFW
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/nsfw
|
||||||
|
*
|
||||||
|
* Returns current user's NSFW settings
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
nsfwEnabled: user.nsfwEnabled,
|
||||||
|
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
|
||||||
|
isNsfw: user.isNsfw, // Whether their account is marked NSFW
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to get settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/settings/nsfw
|
||||||
|
*
|
||||||
|
* Update NSFW settings. Enabling requires age confirmation.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const body = await request.json();
|
||||||
|
const { nsfwEnabled, confirmAge } = updateSchema.parse(body);
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If enabling NSFW and not already verified, require age confirmation
|
||||||
|
if (nsfwEnabled && !user.ageVerifiedAt) {
|
||||||
|
if (!confirmAge) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Age verification required',
|
||||||
|
requiresAgeConfirmation: true,
|
||||||
|
message: 'You must confirm you are 18 or older to view NSFW content',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record age verification
|
||||||
|
await db.update(users)
|
||||||
|
.set({
|
||||||
|
nsfwEnabled: true,
|
||||||
|
ageVerifiedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
nsfwEnabled: true,
|
||||||
|
ageVerifiedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preference (already verified or disabling)
|
||||||
|
await db.update(users)
|
||||||
|
.set({
|
||||||
|
nsfwEnabled,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
nsfwEnabled,
|
||||||
|
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Announce Endpoint
|
||||||
|
*
|
||||||
|
* POST: Receive announcements from other nodes joining the swarm
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { upsertSwarmNode } from '@/lib/swarm/registry';
|
||||||
|
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||||
|
import type { SwarmNodeInfo } from '@/lib/swarm/types';
|
||||||
|
|
||||||
|
const announcementSchema = z.object({
|
||||||
|
domain: z.string().min(1),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
logoUrl: z.string().url().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
softwareVersion: z.string().optional(),
|
||||||
|
userCount: z.number().optional(),
|
||||||
|
postCount: z.number().optional(),
|
||||||
|
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search'])).optional(),
|
||||||
|
timestamp: z.string().optional(),
|
||||||
|
signature: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/swarm/announce
|
||||||
|
*
|
||||||
|
* Receives an announcement from another node and responds with our info.
|
||||||
|
* This is how nodes introduce themselves to the swarm.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const announcement = announcementSchema.parse(body);
|
||||||
|
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
// Don't process announcements from ourselves
|
||||||
|
if (announcement.domain === ourDomain) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot announce to self' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update the announcing node in our registry
|
||||||
|
const nodeInfo: SwarmNodeInfo = {
|
||||||
|
domain: announcement.domain,
|
||||||
|
name: announcement.name,
|
||||||
|
description: announcement.description,
|
||||||
|
logoUrl: announcement.logoUrl,
|
||||||
|
publicKey: announcement.publicKey,
|
||||||
|
softwareVersion: announcement.softwareVersion,
|
||||||
|
userCount: announcement.userCount,
|
||||||
|
postCount: announcement.postCount,
|
||||||
|
capabilities: announcement.capabilities,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement');
|
||||||
|
|
||||||
|
console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${announcement.domain}`);
|
||||||
|
|
||||||
|
// Respond with our own info
|
||||||
|
const ourAnnouncement = await buildAnnouncement();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
domain: ourAnnouncement.domain,
|
||||||
|
name: ourAnnouncement.name,
|
||||||
|
description: ourAnnouncement.description,
|
||||||
|
logoUrl: ourAnnouncement.logoUrl,
|
||||||
|
publicKey: ourAnnouncement.publicKey,
|
||||||
|
softwareVersion: ourAnnouncement.softwareVersion,
|
||||||
|
userCount: ourAnnouncement.userCount,
|
||||||
|
postCount: ourAnnouncement.postCount,
|
||||||
|
capabilities: ourAnnouncement.capabilities,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid announcement payload', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Swarm announce error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process announcement' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Gossip Endpoint
|
||||||
|
*
|
||||||
|
* POST: Exchange node and handle information with other nodes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { processGossip } from '@/lib/swarm/gossip';
|
||||||
|
import { markNodeSuccess } from '@/lib/swarm/registry';
|
||||||
|
import type { SwarmGossipPayload } from '@/lib/swarm/types';
|
||||||
|
|
||||||
|
const handleSchema = z.object({
|
||||||
|
handle: z.string(),
|
||||||
|
did: z.string(),
|
||||||
|
nodeDomain: z.string(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeInfoSchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
logoUrl: z.string().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
softwareVersion: z.string().optional(),
|
||||||
|
userCount: z.number().optional(),
|
||||||
|
postCount: z.number().optional(),
|
||||||
|
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search'])).optional(),
|
||||||
|
lastSeenAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gossipPayloadSchema = z.object({
|
||||||
|
sender: z.string().min(1),
|
||||||
|
nodes: z.array(nodeInfoSchema),
|
||||||
|
handles: z.array(handleSchema).optional(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
since: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/swarm/gossip
|
||||||
|
*
|
||||||
|
* Receives gossip from another node and responds with our own data.
|
||||||
|
* This is the core of the epidemic protocol - nodes exchange what they know.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const payload = gossipPayloadSchema.parse(body) as SwarmGossipPayload;
|
||||||
|
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
// Don't process gossip from ourselves
|
||||||
|
if (payload.sender === ourDomain) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot gossip with self' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Swarm] Gossip from ${payload.sender}: ${payload.nodes.length} nodes, ${payload.handles?.length || 0} handles`);
|
||||||
|
|
||||||
|
// Process the incoming gossip and build our response
|
||||||
|
const response = await processGossip(payload);
|
||||||
|
|
||||||
|
// Mark the sender as successfully contacted
|
||||||
|
await markNodeSuccess(payload.sender);
|
||||||
|
|
||||||
|
console.log(`[Swarm] Gossip response to ${payload.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`);
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid gossip payload', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Swarm gossip error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process gossip' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Info Endpoint
|
||||||
|
*
|
||||||
|
* GET: Returns this node's public swarm information
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/swarm/info
|
||||||
|
*
|
||||||
|
* Returns this node's public information for the swarm.
|
||||||
|
* Used by other nodes during discovery.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const announcement = await buildAnnouncement();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
domain: announcement.domain,
|
||||||
|
name: announcement.name,
|
||||||
|
description: announcement.description,
|
||||||
|
logoUrl: announcement.logoUrl,
|
||||||
|
publicKey: announcement.publicKey,
|
||||||
|
softwareVersion: announcement.softwareVersion,
|
||||||
|
userCount: announcement.userCount,
|
||||||
|
postCount: announcement.postCount,
|
||||||
|
capabilities: announcement.capabilities,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm info error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get node info' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Nodes Endpoint
|
||||||
|
*
|
||||||
|
* GET: List all known nodes in the swarm
|
||||||
|
* POST: Trigger discovery/sync operations (admin only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import {
|
||||||
|
getActiveSwarmNodes,
|
||||||
|
getSwarmStats,
|
||||||
|
addSeedNode,
|
||||||
|
} from '@/lib/swarm/registry';
|
||||||
|
import {
|
||||||
|
announceToSeeds,
|
||||||
|
announceToNode,
|
||||||
|
discoverNode,
|
||||||
|
} from '@/lib/swarm/discovery';
|
||||||
|
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/swarm/nodes
|
||||||
|
*
|
||||||
|
* Returns list of known swarm nodes and stats.
|
||||||
|
* Public endpoint - anyone can see the swarm.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
|
||||||
|
const includeStats = searchParams.get('stats') === 'true';
|
||||||
|
|
||||||
|
const nodes = await getActiveSwarmNodes(limit);
|
||||||
|
|
||||||
|
const response: {
|
||||||
|
nodes: typeof nodes;
|
||||||
|
stats?: Awaited<ReturnType<typeof getSwarmStats>>;
|
||||||
|
} = { nodes };
|
||||||
|
|
||||||
|
if (includeStats) {
|
||||||
|
response.stats = await getSwarmStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm nodes error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch swarm nodes' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
action: z.enum(['announce', 'discover', 'gossip', 'addSeed']),
|
||||||
|
domain: z.string().optional(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/swarm/nodes
|
||||||
|
*
|
||||||
|
* Admin-only endpoint to trigger swarm operations:
|
||||||
|
* - announce: Announce to seeds or a specific node
|
||||||
|
* - discover: Discover a specific node
|
||||||
|
* - gossip: Run a gossip round or gossip with specific node
|
||||||
|
* - addSeed: Add a new seed node
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { action, domain, priority } = actionSchema.parse(body);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'announce': {
|
||||||
|
if (domain) {
|
||||||
|
const result = await announceToNode(domain);
|
||||||
|
return NextResponse.json({ action, domain, ...result });
|
||||||
|
} else {
|
||||||
|
const result = await announceToSeeds();
|
||||||
|
return NextResponse.json({ action, ...result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'discover': {
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Domain required for discover action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await discoverNode(domain);
|
||||||
|
return NextResponse.json({ action, domain, ...result });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gossip': {
|
||||||
|
if (domain) {
|
||||||
|
const result = await gossipToNode(domain);
|
||||||
|
return NextResponse.json({ action, domain, ...result });
|
||||||
|
} else {
|
||||||
|
const result = await runGossipRound();
|
||||||
|
return NextResponse.json({ action, ...result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'addSeed': {
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Domain required for addSeed action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await addSeedNode(domain, priority);
|
||||||
|
return NextResponse.json({ action, domain, success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unknown action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid payload', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Swarm nodes POST error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to execute swarm action' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Timeline Endpoint
|
||||||
|
*
|
||||||
|
* GET: Returns recent public posts from this node for the swarm timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, posts, users, media, nodes } from '@/db';
|
||||||
|
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface SwarmPost {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: {
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
isNsfw: boolean;
|
||||||
|
};
|
||||||
|
nodeDomain: string;
|
||||||
|
nodeIsNsfw: boolean;
|
||||||
|
isNsfw: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
repostCount: number;
|
||||||
|
replyCount: number;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/swarm/timeline
|
||||||
|
*
|
||||||
|
* Returns recent public posts from this node.
|
||||||
|
* Used by other nodes to build the swarm-wide timeline.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
|
|
||||||
|
// Get node NSFW status
|
||||||
|
const node = await db.query.nodes.findFirst({
|
||||||
|
where: eq(nodes.domain, nodeDomain),
|
||||||
|
});
|
||||||
|
const nodeIsNsfw = node?.isNsfw ?? false;
|
||||||
|
|
||||||
|
// Get recent public posts (not replies, local users only, not removed)
|
||||||
|
const recentPosts = await db
|
||||||
|
.select({
|
||||||
|
id: posts.id,
|
||||||
|
content: posts.content,
|
||||||
|
createdAt: posts.createdAt,
|
||||||
|
isNsfw: posts.isNsfw,
|
||||||
|
likesCount: posts.likesCount,
|
||||||
|
repostsCount: posts.repostsCount,
|
||||||
|
repliesCount: posts.repliesCount,
|
||||||
|
authorHandle: users.handle,
|
||||||
|
authorDisplayName: users.displayName,
|
||||||
|
authorAvatarUrl: users.avatarUrl,
|
||||||
|
authorIsNsfw: users.isNsfw,
|
||||||
|
authorNodeId: users.nodeId,
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(posts.replyToId), // Not a reply
|
||||||
|
eq(posts.isRemoved, false) // Not removed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
// Fetch media for each post
|
||||||
|
const swarmPosts: SwarmPost[] = [];
|
||||||
|
|
||||||
|
for (const post of recentPosts) {
|
||||||
|
const postMedia = await db
|
||||||
|
.select({ url: media.url })
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.postId, post.id));
|
||||||
|
|
||||||
|
swarmPosts.push({
|
||||||
|
id: post.id,
|
||||||
|
content: post.content,
|
||||||
|
createdAt: post.createdAt.toISOString(),
|
||||||
|
author: {
|
||||||
|
handle: post.authorHandle,
|
||||||
|
displayName: post.authorDisplayName || post.authorHandle,
|
||||||
|
avatarUrl: post.authorAvatarUrl || undefined,
|
||||||
|
isNsfw: post.authorIsNsfw,
|
||||||
|
},
|
||||||
|
nodeDomain,
|
||||||
|
nodeIsNsfw,
|
||||||
|
isNsfw: post.isNsfw || post.authorIsNsfw || nodeIsNsfw, // Cascade NSFW flag
|
||||||
|
likeCount: post.likesCount,
|
||||||
|
repostCount: post.repostsCount,
|
||||||
|
replyCount: post.repliesCount,
|
||||||
|
mediaUrls: postMedia.length > 0 ? postMedia.map(m => m.url) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: swarmPosts,
|
||||||
|
nodeDomain,
|
||||||
|
nodeIsNsfw,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Swarm timeline error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch timeline' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { users, blocks, follows } from '@/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
// GET - Check if blocked
|
||||||
|
export async function GET(req: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = await db.query.blocks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(blocks.userId, currentUser.id),
|
||||||
|
eq(blocks.blockedUserId, targetUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ blocked: !!block });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Check block error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to check block status' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Block user
|
||||||
|
export async function POST(req: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.id === currentUser.id) {
|
||||||
|
return NextResponse.json({ error: 'Cannot block yourself' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already blocked
|
||||||
|
const existing = await db.query.blocks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(blocks.userId, currentUser.id),
|
||||||
|
eq(blocks.blockedUserId, targetUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ blocked: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create block
|
||||||
|
await db.insert(blocks).values({
|
||||||
|
userId: currentUser.id,
|
||||||
|
blockedUserId: targetUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any follows between the users
|
||||||
|
await db.delete(follows).where(
|
||||||
|
and(
|
||||||
|
eq(follows.followerId, currentUser.id),
|
||||||
|
eq(follows.followingId, targetUser.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await db.delete(follows).where(
|
||||||
|
and(
|
||||||
|
eq(follows.followerId, targetUser.id),
|
||||||
|
eq(follows.followingId, currentUser.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ blocked: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Block user error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to block user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Unblock user
|
||||||
|
export async function DELETE(req: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(blocks).where(
|
||||||
|
and(
|
||||||
|
eq(blocks.userId, currentUser.id),
|
||||||
|
eq(blocks.blockedUserId, targetUser.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ blocked: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Unblock user error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-25
@@ -6,7 +6,7 @@ import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
|||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { Bot, Globe, Server } from 'lucide-react';
|
import { Bot, Network, Server } from 'lucide-react';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,11 +58,28 @@ function UserCard({ user }: { user: User }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SwarmPost {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: {
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
nodeDomain: string;
|
||||||
|
likeCount: number;
|
||||||
|
repostCount: number;
|
||||||
|
replyCount: number;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'node' | 'fediverse' | 'users' | 'search'>('node');
|
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
||||||
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
||||||
const [fediversePosts, setFediversePosts] = useState<Post[]>([]);
|
const [swarmPosts, setSwarmPosts] = useState<SwarmPost[]>([]);
|
||||||
|
const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -87,23 +104,24 @@ export default function ExplorePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load fediverse posts when tab changes
|
// Load swarm posts when tab changes
|
||||||
if (activeTab === 'fediverse' && fediversePosts.length === 0) {
|
if (activeTab === 'swarm' && swarmPosts.length === 0) {
|
||||||
const loadFediverse = async () => {
|
const loadSwarm = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/posts?type=curated&limit=20');
|
const res = await fetch('/api/posts/swarm');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setFediversePosts(data.posts || []);
|
setSwarmPosts(data.posts || []);
|
||||||
|
setSwarmSources(data.sources || []);
|
||||||
} catch {
|
} catch {
|
||||||
setFediversePosts([]);
|
setSwarmPosts([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadFediverse();
|
loadSwarm();
|
||||||
}
|
}
|
||||||
}, [activeTab, fediversePosts.length]);
|
}, [activeTab, swarmPosts.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load users when tab changes to users
|
// Load users when tab changes to users
|
||||||
@@ -157,7 +175,7 @@ export default function ExplorePage() {
|
|||||||
|
|
||||||
const handleDelete = (postId: string) => {
|
const handleDelete = (postId: string) => {
|
||||||
setNodePosts(prev => prev.filter(p => p.id !== postId));
|
setNodePosts(prev => prev.filter(p => p.id !== postId));
|
||||||
setFediversePosts(prev => prev.filter(p => p.id !== postId));
|
setSwarmPosts(prev => prev.filter(p => p.id !== postId));
|
||||||
setSearchResults(prev => ({
|
setSearchResults(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
posts: prev.posts.filter(p => p.id !== postId)
|
posts: prev.posts.filter(p => p.id !== postId)
|
||||||
@@ -188,11 +206,11 @@ export default function ExplorePage() {
|
|||||||
<span>Node</span>
|
<span>Node</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`explore-tab ${activeTab === 'fediverse' ? 'active' : ''}`}
|
className={`explore-tab ${activeTab === 'swarm' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('fediverse')}
|
onClick={() => setActiveTab('swarm')}
|
||||||
>
|
>
|
||||||
<Globe size={18} />
|
<Network size={18} />
|
||||||
<span>Fediverse</span>
|
<span>Swarm</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
|
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
|
||||||
@@ -238,25 +256,62 @@ export default function ExplorePage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'fediverse' && (
|
{activeTab === 'swarm' && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<div className="explore-loading">Loading fediverse posts...</div>
|
<div className="explore-loading">Loading swarm posts...</div>
|
||||||
) : fediversePosts.length === 0 ? (
|
) : swarmPosts.length === 0 ? (
|
||||||
<div className="explore-empty">
|
<div className="explore-empty">
|
||||||
<Globe size={24} />
|
<Network size={24} />
|
||||||
<p>No fediverse posts yet</p>
|
<p>No swarm posts yet</p>
|
||||||
|
<p style={{ fontSize: '14px', opacity: 0.7, marginTop: '8px' }}>
|
||||||
|
Posts from other Synapsis nodes will appear here
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="feed-meta card">
|
<div className="feed-meta card">
|
||||||
<div className="feed-meta-title">Fediverse feed</div>
|
<div className="feed-meta-title">Swarm feed</div>
|
||||||
<div className="feed-meta-body">
|
<div className="feed-meta-body">
|
||||||
This feed shows posts from across the fediverse, including content from accounts that users on this node follow. Discover new voices and conversations from the wider federated network.
|
Posts from across the Synapsis network. Currently showing posts from {swarmSources.filter(s => s.postCount > 0).length} node{swarmSources.filter(s => s.postCount > 0).length !== 1 ? 's' : ''}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="explore-posts">
|
<div className="explore-posts">
|
||||||
{fediversePosts.map((post) => (
|
{swarmPosts.map((post) => (
|
||||||
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
<div key={`${post.nodeDomain}:${post.id}`} className="swarm-post-wrapper">
|
||||||
|
<div className="swarm-post-card card">
|
||||||
|
<div className="swarm-post-header">
|
||||||
|
<div className="avatar">
|
||||||
|
{post.author.avatarUrl ? (
|
||||||
|
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||||
|
) : (
|
||||||
|
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="swarm-post-meta">
|
||||||
|
<span className="swarm-post-author">{post.author.displayName}</span>
|
||||||
|
<span className="swarm-post-handle">@{post.author.handle}@{post.nodeDomain}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="swarm-post-content">{post.content}</div>
|
||||||
|
{post.mediaUrls && post.mediaUrls.length > 0 && (
|
||||||
|
<div className="swarm-post-media">
|
||||||
|
{post.mediaUrls.map((url, i) => (
|
||||||
|
<img key={i} src={url} alt="" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="swarm-post-footer">
|
||||||
|
<span className="swarm-post-time">
|
||||||
|
{new Date(post.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="swarm-post-stats">
|
||||||
|
{post.likeCount > 0 && `${post.likeCount} likes`}
|
||||||
|
{post.likeCount > 0 && post.repostCount > 0 && ' · '}
|
||||||
|
{post.repostCount > 0 && `${post.repostCount} reposts`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -467,6 +467,42 @@ a.btn-primary:visited {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-nsfw-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-nsfw-toggle:hover {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-nsfw-toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-nsfw-toggle input:checked + svg {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-nsfw-toggle input:checked ~ span {
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-actions {
|
.compose-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1373,6 +1409,81 @@ a.btn-primary:visited {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Swarm Post Styles */
|
||||||
|
.swarm-post-wrapper {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-card {
|
||||||
|
padding: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-card:hover {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-author {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-handle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--foreground);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-media {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-media img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swarm-post-stats {
|
||||||
|
color: var(--foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
/* Scrollbar Styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
+2
-2
@@ -55,11 +55,11 @@ export default function Home() {
|
|||||||
loadFeed(feedType);
|
loadFeed(feedType);
|
||||||
}, [feedType]);
|
}, [feedType]);
|
||||||
|
|
||||||
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => {
|
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
|
||||||
const res = await fetch('/api/posts', {
|
const res = await fetch('/api/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }),
|
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
|
import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NsfwSettings {
|
||||||
|
nsfwEnabled: boolean;
|
||||||
|
ageVerifiedAt: string | null;
|
||||||
|
isNsfw: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentSettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<NsfwSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAgeModal, setShowAgeModal] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/nsfw');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSettings(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load settings');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleNsfw = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
// If enabling and not verified, show age modal
|
||||||
|
if (!settings.nsfwEnabled && !settings.ageVerifiedAt) {
|
||||||
|
setShowAgeModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise just toggle
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/nsfw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ nsfwEnabled: !settings.nsfwEnabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSettings(prev => prev ? { ...prev, nsfwEnabled: data.nsfwEnabled } : null);
|
||||||
|
setSuccess(data.nsfwEnabled ? 'NSFW content enabled' : 'NSFW content disabled');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || 'Failed to update');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to update settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgeConfirm = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/nsfw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ nsfwEnabled: true, confirmAge: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSettings(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
nsfwEnabled: true,
|
||||||
|
ageVerifiedAt: data.ageVerifiedAt,
|
||||||
|
} : null);
|
||||||
|
setShowAgeModal(false);
|
||||||
|
setSuccess('NSFW content enabled');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || 'Failed to verify');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to verify age');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAccountNsfw = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/account-nsfw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ isNsfw: !settings.isNsfw }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSettings(prev => prev ? { ...prev, isNsfw: data.isNsfw } : null);
|
||||||
|
setSuccess(data.isNsfw ? 'Account marked as NSFW' : 'Account unmarked as NSFW');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || 'Failed to update');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to update settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
|
<header style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
}}>
|
||||||
|
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Content Settings</h1>
|
||||||
|
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
|
NSFW and content visibility preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'var(--error-muted)',
|
||||||
|
color: 'var(--error)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'var(--success-muted)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<Check size={16} />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{/* View NSFW Content */}
|
||||||
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
{settings?.nsfwEnabled ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||||
|
Show NSFW Content
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
{settings?.nsfwEnabled
|
||||||
|
? 'You can see posts marked as sensitive or from NSFW accounts/nodes.'
|
||||||
|
: 'NSFW content is hidden from your feeds and search results.'}
|
||||||
|
</div>
|
||||||
|
{settings?.ageVerifiedAt && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
Age verified on {new Date(settings.ageVerifiedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleNsfw}
|
||||||
|
disabled={saving}
|
||||||
|
className={`btn btn-sm ${settings?.nsfwEnabled ? 'btn-ghost' : 'btn-primary'}`}
|
||||||
|
>
|
||||||
|
{settings?.nsfwEnabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mark Account as NSFW - only show if NSFW viewing is enabled */}
|
||||||
|
{settings?.nsfwEnabled && (
|
||||||
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
Mark My Account as NSFW
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
{settings?.isNsfw
|
||||||
|
? 'Your account is marked as NSFW. All your posts will be hidden from users who haven\'t enabled NSFW content.'
|
||||||
|
: 'Enable this if you regularly post adult or sensitive content. Your posts will only be visible to users who have enabled NSFW viewing.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAccountNsfw}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: settings?.isNsfw ? 'none' : '1px solid var(--border)',
|
||||||
|
background: settings?.isNsfw ? 'var(--error)' : 'var(--background-secondary)',
|
||||||
|
color: settings?.isNsfw ? 'white' : 'var(--foreground)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: saving ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.isNsfw ? 'Remove' : 'Mark NSFW'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info box */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
How NSFW filtering works
|
||||||
|
</div>
|
||||||
|
<ul style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
<li>Content is marked NSFW at three levels: post, account, or node</li>
|
||||||
|
<li>If any level is NSFW, the content is hidden from non-verified users</li>
|
||||||
|
<li>You can mark individual posts as NSFW when composing</li>
|
||||||
|
<li>NSFW content still syncs across the swarm, but is filtered at display time</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Age Verification Modal */}
|
||||||
|
{showAgeModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '16px',
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '24px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={24} color="var(--warning)" />
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Age Verification</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
NSFW content may include adult themes, nudity, or other sensitive material.
|
||||||
|
By enabling this setting, you confirm that you are at least 18 years old.
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAgeModal(false)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAgeConfirm}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{saving ? 'Confirming...' : 'I am 18 or older'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
|
import { UserX, Globe, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BlockedUser {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
blockedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutedNode {
|
||||||
|
domain: string;
|
||||||
|
mutedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModerationSettingsPage() {
|
||||||
|
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
||||||
|
const [mutedNodes, setMutedNodes] = useState<MutedNode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newNodeDomain, setNewNodeDomain] = useState('');
|
||||||
|
const [addingNode, setAddingNode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [blockedRes, mutedRes] = await Promise.all([
|
||||||
|
fetch('/api/settings/blocked-users'),
|
||||||
|
fetch('/api/settings/muted-nodes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (blockedRes.ok) {
|
||||||
|
const data = await blockedRes.json();
|
||||||
|
setBlockedUsers(data.blockedUsers || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutedRes.ok) {
|
||||||
|
const data = await mutedRes.json();
|
||||||
|
setMutedNodes(data.mutedNodes || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load moderation settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnblock = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/settings/blocked-users?userId=${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setBlockedUsers(prev => prev.filter(u => u.id !== userId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unblock user:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnmuteNode = async (domain: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/settings/muted-nodes?domain=${encodeURIComponent(domain)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMutedNodes(prev => prev.filter(n => n.domain !== domain));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unmute node:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMuteNode = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newNodeDomain.trim() || addingNode) return;
|
||||||
|
|
||||||
|
setAddingNode(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/muted-nodes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain: newNodeDomain.trim() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMutedNodes(prev => [
|
||||||
|
{ domain: data.domain, mutedAt: new Date().toISOString() },
|
||||||
|
...prev.filter(n => n.domain !== data.domain),
|
||||||
|
]);
|
||||||
|
setNewNodeDomain('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mute node:', error);
|
||||||
|
} finally {
|
||||||
|
setAddingNode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
|
<header style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
}}>
|
||||||
|
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Moderation</h1>
|
||||||
|
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
|
Manage blocked users and muted nodes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{/* Blocked Users */}
|
||||||
|
<section>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
<UserX size={18} />
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Blocked Users</h2>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}>
|
||||||
|
{blockedUsers.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blockedUsers.length === 0 ? (
|
||||||
|
<div className="card" style={{
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
You haven't blocked anyone
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ overflow: 'hidden' }}>
|
||||||
|
{blockedUsers.map((user, i) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: i < blockedUsers.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/@${user.handle}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl || '/default-avatar.png'}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
{user.displayName || user.handle}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
@{user.handle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnblock(user.id)}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
Unblock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Muted Nodes */}
|
||||||
|
<section>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
<Globe size={18} />
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Muted Nodes</h2>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}>
|
||||||
|
{mutedNodes.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
Posts from muted nodes won't appear in your feeds or search results.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleMuteNode} style={{ marginBottom: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
placeholder="node.example.com"
|
||||||
|
value={newNodeDomain}
|
||||||
|
onChange={(e) => setNewNodeDomain(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!newNodeDomain.trim() || addingNode}
|
||||||
|
>
|
||||||
|
{addingNode ? 'Adding...' : 'Mute'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mutedNodes.length === 0 ? (
|
||||||
|
<div className="card" style={{
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
No muted nodes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ overflow: 'hidden' }}>
|
||||||
|
{mutedNodes.map((node, i) => (
|
||||||
|
<div
|
||||||
|
key={node.domain}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: i < mutedNodes.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{node.domain}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Muted {new Date(node.mutedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnmuteNode(node.domain)}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { Rocket, Shield, Bell, Bot } from 'lucide-react';
|
import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +41,38 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/content" className="card" style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '20px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Eye size={18} />
|
||||||
|
Content Settings
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
NSFW preferences and content visibility
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/moderation" className="card" style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '20px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<UserX size={18} />
|
||||||
|
Moderation
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
|
Blocked users and muted nodes
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/migration" className="card" style={{
|
<Link href="/settings/migration" className="card" style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Post, Attachment } from '@/lib/types';
|
import { Post, Attachment } from '@/lib/types';
|
||||||
import { ImageIcon } from 'lucide-react';
|
import { ImageIcon, AlertTriangle } from 'lucide-react';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
|
|
||||||
interface ComposeProps {
|
interface ComposeProps {
|
||||||
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => void;
|
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void;
|
||||||
replyingTo?: Post | null;
|
replyingTo?: Post | null;
|
||||||
onCancelReply?: () => void;
|
onCancelReply?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -24,9 +24,23 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
const [linkPreview, setLinkPreview] = useState<any>(null);
|
const [linkPreview, setLinkPreview] = useState<any>(null);
|
||||||
const [fetchingPreview, setFetchingPreview] = useState(false);
|
const [fetchingPreview, setFetchingPreview] = useState(false);
|
||||||
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
||||||
|
const [isNsfw, setIsNsfw] = useState(false);
|
||||||
|
const [canPostNsfw, setCanPostNsfw] = useState(false);
|
||||||
const maxLength = 400;
|
const maxLength = 400;
|
||||||
const remaining = maxLength - content.length;
|
const remaining = maxLength - content.length;
|
||||||
|
|
||||||
|
// Check if user can post NSFW content
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/settings/nsfw')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data?.nsfwEnabled) {
|
||||||
|
setCanPostNsfw(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Detect URLs in content
|
// Detect URLs in content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
|
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
|
||||||
@@ -62,11 +76,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!content.trim() || isPosting || isUploading) return;
|
if (!content.trim() || isPosting || isUploading) return;
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id);
|
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setLinkPreview(null);
|
setLinkPreview(null);
|
||||||
setLastDetectedUrl(null);
|
setLastDetectedUrl(null);
|
||||||
|
setIsNsfw(false);
|
||||||
setIsPosting(false);
|
setIsPosting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,9 +198,22 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
<div className="compose-media-error">{uploadError}</div>
|
<div className="compose-media-error">{uploadError}</div>
|
||||||
)}
|
)}
|
||||||
<div className="compose-footer">
|
<div className="compose-footer">
|
||||||
|
<div className="compose-footer-left">
|
||||||
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
|
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
|
||||||
{remaining}
|
{remaining}
|
||||||
</span>
|
</span>
|
||||||
|
{canPostNsfw && (
|
||||||
|
<label className="compose-nsfw-toggle" title="Mark as sensitive content">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isNsfw}
|
||||||
|
onChange={(e) => setIsNsfw(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>NSFW</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="compose-actions">
|
<div className="compose-actions">
|
||||||
<label className="compose-media-button" title="Add media">
|
<label className="compose-media-button" title="Add media">
|
||||||
{isUploading ? '...' : <ImageIcon size={20} />}
|
{isUploading ? '...' : <ImageIcon size={20} />}
|
||||||
|
|||||||
+211
-2
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { useToast } from '@/lib/contexts/ToastContext';
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
@@ -33,16 +33,18 @@ interface PostCardProps {
|
|||||||
onRepost?: (id: string, currentReposted: boolean) => void;
|
onRepost?: (id: string, currentReposted: boolean) => void;
|
||||||
onComment?: (post: Post) => void;
|
onComment?: (post: Post) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onHide?: (id: string) => void; // Called when post should be hidden (block/mute)
|
||||||
isDetail?: boolean;
|
isDetail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) {
|
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail }: PostCardProps) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [liked, setLiked] = useState(post.isLiked || false);
|
const [liked, setLiked] = useState(post.isLiked || false);
|
||||||
const [reposted, setReposted] = useState(post.isReposted || false);
|
const [reposted, setReposted] = useState(post.isReposted || false);
|
||||||
const [reporting, setReporting] = useState(false);
|
const [reporting, setReporting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
// Sync state if post changes (e.g. after a re-render from parent)
|
// Sync state if post changes (e.g. after a re-render from parent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,6 +152,95 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlockUser = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMenu(false);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
showToast('Please log in to block users', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${post.author.handle}/block`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Blocked @${post.author.handle}`, 'success');
|
||||||
|
onHide?.(post.id);
|
||||||
|
} else {
|
||||||
|
showToast('Failed to block user', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to block user', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMuteUser = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMenu(false);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
showToast('Please log in to mute users', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, muting a user is the same as blocking but with different messaging
|
||||||
|
// Could be expanded to just hide posts without breaking follows
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${post.author.handle}/block`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Muted @${post.author.handle}`, 'success');
|
||||||
|
onHide?.(post.id);
|
||||||
|
} else {
|
||||||
|
showToast('Failed to mute user', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to mute user', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMuteNode = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMenu(false);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
showToast('Please log in to mute nodes', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract node domain from the post
|
||||||
|
const nodeDomain = post.nodeDomain || (post.author.handle.includes('@')
|
||||||
|
? post.author.handle.split('@')[1]
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (!nodeDomain) {
|
||||||
|
showToast('Cannot determine node for this post', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/muted-nodes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain: nodeDomain }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Muted node: ${nodeDomain}`, 'success');
|
||||||
|
onHide?.(post.id);
|
||||||
|
} else {
|
||||||
|
showToast('Failed to mute node', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to mute node', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const postUrl = `/${post.author.handle}/posts/${post.id}`;
|
const postUrl = `/${post.author.handle}/posts/${post.id}`;
|
||||||
|
|
||||||
// Decode HTML entities from federated posts (e.g., &rsquo; -> ')
|
// Decode HTML entities from federated posts (e.g., &rsquo; -> ')
|
||||||
@@ -290,6 +381,124 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
|||||||
</div>
|
</div>
|
||||||
<span className="post-time">{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}</span>
|
<span className="post-time">{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{currentUser && currentUser.id !== post.author.id && (
|
||||||
|
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||||
|
<button
|
||||||
|
className="post-menu-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMenu(!showMenu);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={18} />
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 99,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="post-menu-dropdown"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '100%',
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
minWidth: '180px',
|
||||||
|
zIndex: 100,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleMuteUser}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VolumeX size={16} />
|
||||||
|
Mute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBlockUser}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserX size={16} />
|
||||||
|
Block
|
||||||
|
</button>
|
||||||
|
{(post.nodeDomain || post.author.handle.includes('@')) && (
|
||||||
|
<button
|
||||||
|
onClick={handleMuteNode}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
Mute node
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{post.replyTo && (
|
{post.replyTo && (
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const nodes = pgTable('nodes', {
|
|||||||
logoUrl: text('logo_url'),
|
logoUrl: text('logo_url'),
|
||||||
accentColor: text('accent_color').default('#FFFFFF'),
|
accentColor: text('accent_color').default('#FFFFFF'),
|
||||||
publicKey: text('public_key'),
|
publicKey: text('public_key'),
|
||||||
|
// NSFW settings
|
||||||
|
isNsfw: boolean('is_nsfw').default(false).notNull(), // Entire node is NSFW
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
@@ -40,6 +42,10 @@ export const users = pgTable('users', {
|
|||||||
// Bot-related fields
|
// Bot-related fields
|
||||||
isBot: boolean('is_bot').default(false).notNull(),
|
isBot: boolean('is_bot').default(false).notNull(),
|
||||||
botOwnerId: uuid('bot_owner_id'),
|
botOwnerId: uuid('bot_owner_id'),
|
||||||
|
// NSFW settings
|
||||||
|
isNsfw: boolean('is_nsfw').default(false).notNull(), // Account produces NSFW content
|
||||||
|
nsfwEnabled: boolean('nsfw_enabled').default(false).notNull(), // User wants to see NSFW content
|
||||||
|
ageVerifiedAt: timestamp('age_verified_at'), // When user confirmed 18+
|
||||||
// Moderation fields
|
// Moderation fields
|
||||||
isSuspended: boolean('is_suspended').default(false).notNull(),
|
isSuspended: boolean('is_suspended').default(false).notNull(),
|
||||||
suspensionReason: text('suspension_reason'),
|
suspensionReason: text('suspension_reason'),
|
||||||
@@ -64,6 +70,7 @@ export const users = pgTable('users', {
|
|||||||
index('users_silenced_idx').on(table.isSilenced),
|
index('users_silenced_idx').on(table.isSilenced),
|
||||||
index('users_is_bot_idx').on(table.isBot),
|
index('users_is_bot_idx').on(table.isBot),
|
||||||
index('users_bot_owner_idx').on(table.botOwnerId),
|
index('users_bot_owner_idx').on(table.botOwnerId),
|
||||||
|
index('users_nsfw_idx').on(table.isNsfw),
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.botOwnerId],
|
columns: [table.botOwnerId],
|
||||||
foreignColumns: [table.id],
|
foreignColumns: [table.id],
|
||||||
@@ -101,6 +108,9 @@ export const posts = pgTable('posts', {
|
|||||||
likesCount: integer('likes_count').default(0).notNull(),
|
likesCount: integer('likes_count').default(0).notNull(),
|
||||||
repostsCount: integer('reposts_count').default(0).notNull(),
|
repostsCount: integer('reposts_count').default(0).notNull(),
|
||||||
repliesCount: integer('replies_count').default(0).notNull(),
|
repliesCount: integer('replies_count').default(0).notNull(),
|
||||||
|
// NSFW
|
||||||
|
isNsfw: boolean('is_nsfw').default(false).notNull(), // This specific post is NSFW
|
||||||
|
// Moderation
|
||||||
isRemoved: boolean('is_removed').default(false).notNull(),
|
isRemoved: boolean('is_removed').default(false).notNull(),
|
||||||
removedAt: timestamp('removed_at'),
|
removedAt: timestamp('removed_at'),
|
||||||
removedBy: uuid('removed_by').references(() => users.id),
|
removedBy: uuid('removed_by').references(() => users.id),
|
||||||
@@ -121,6 +131,7 @@ export const posts = pgTable('posts', {
|
|||||||
index('posts_created_at_idx').on(table.createdAt),
|
index('posts_created_at_idx').on(table.createdAt),
|
||||||
index('posts_reply_to_idx').on(table.replyToId),
|
index('posts_reply_to_idx').on(table.replyToId),
|
||||||
index('posts_removed_idx').on(table.isRemoved),
|
index('posts_removed_idx').on(table.isRemoved),
|
||||||
|
index('posts_nsfw_idx').on(table.isNsfw),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const postsRelations = relations(posts, ({ one, many }) => ({
|
export const postsRelations = relations(posts, ({ one, many }) => ({
|
||||||
@@ -386,8 +397,20 @@ export const blocks = pgTable('blocks', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('blocks_user_idx').on(table.userId),
|
index('blocks_user_idx').on(table.userId),
|
||||||
|
index('blocks_blocked_user_idx').on(table.blockedUserId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const blocksRelations = relations(blocks, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [blocks.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
blockedUser: one(users, {
|
||||||
|
fields: [blocks.blockedUserId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const mutes = pgTable('mutes', {
|
export const mutes = pgTable('mutes', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
@@ -395,8 +418,38 @@ export const mutes = pgTable('mutes', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('mutes_user_idx').on(table.userId),
|
index('mutes_user_idx').on(table.userId),
|
||||||
|
index('mutes_muted_user_idx').on(table.mutedUserId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const mutesRelations = relations(mutes, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [mutes.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
mutedUser: one(users, {
|
||||||
|
fields: [mutes.mutedUserId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Muted nodes - hide all content from specific swarm nodes
|
||||||
|
export const mutedNodes = pgTable('muted_nodes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
nodeDomain: text('node_domain').notNull(), // Domain of the muted node
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('muted_nodes_user_idx').on(table.userId),
|
||||||
|
index('muted_nodes_domain_idx').on(table.nodeDomain),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const mutedNodesRelations = relations(mutedNodes, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [mutedNodes.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// REPORTS (moderation)
|
// REPORTS (moderation)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -658,3 +711,108 @@ export const botRateLimitsRelations = relations(botRateLimits, ({ one }) => ({
|
|||||||
references: [bots.id],
|
references: [bots.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SWARM - Node Discovery Network
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovered nodes in the swarm network.
|
||||||
|
* Tracks all known Synapsis nodes discovered through gossip or seed nodes.
|
||||||
|
*/
|
||||||
|
export const swarmNodes = pgTable('swarm_nodes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
domain: text('domain').notNull().unique(),
|
||||||
|
|
||||||
|
// Node metadata (fetched from remote)
|
||||||
|
name: text('name'),
|
||||||
|
description: text('description'),
|
||||||
|
logoUrl: text('logo_url'),
|
||||||
|
publicKey: text('public_key'),
|
||||||
|
softwareVersion: text('software_version'),
|
||||||
|
|
||||||
|
// Stats (updated periodically)
|
||||||
|
userCount: integer('user_count'),
|
||||||
|
postCount: integer('post_count'),
|
||||||
|
|
||||||
|
// NSFW flag (synced from remote node)
|
||||||
|
isNsfw: boolean('is_nsfw').default(false).notNull(),
|
||||||
|
|
||||||
|
// Discovery metadata
|
||||||
|
discoveredVia: text('discovered_via'), // Domain of node that told us about this one
|
||||||
|
discoveredAt: timestamp('discovered_at').defaultNow().notNull(),
|
||||||
|
|
||||||
|
// Health tracking
|
||||||
|
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
||||||
|
lastSyncAt: timestamp('last_sync_at'),
|
||||||
|
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
|
||||||
|
// Trust/reputation (for future spam prevention)
|
||||||
|
trustScore: integer('trust_score').default(50).notNull(), // 0-100
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('swarm_nodes_domain_idx').on(table.domain),
|
||||||
|
index('swarm_nodes_active_idx').on(table.isActive),
|
||||||
|
index('swarm_nodes_last_seen_idx').on(table.lastSeenAt),
|
||||||
|
index('swarm_nodes_trust_idx').on(table.trustScore),
|
||||||
|
index('swarm_nodes_nsfw_idx').on(table.isNsfw),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed nodes - well-known entry points to the swarm.
|
||||||
|
* These are the bootstrap nodes that new nodes contact first.
|
||||||
|
*/
|
||||||
|
export const swarmSeeds = pgTable('swarm_seeds', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
domain: text('domain').notNull().unique(),
|
||||||
|
|
||||||
|
// Priority for connection order (lower = higher priority)
|
||||||
|
priority: integer('priority').default(100).notNull(),
|
||||||
|
|
||||||
|
// Whether this seed is enabled
|
||||||
|
isEnabled: boolean('is_enabled').default(true).notNull(),
|
||||||
|
|
||||||
|
// Health tracking
|
||||||
|
lastContactAt: timestamp('last_contact_at'),
|
||||||
|
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('swarm_seeds_enabled_idx').on(table.isEnabled),
|
||||||
|
index('swarm_seeds_priority_idx').on(table.priority),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swarm sync log - tracks gossip exchanges between nodes.
|
||||||
|
*/
|
||||||
|
export const swarmSyncLog = pgTable('swarm_sync_log', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Which node we synced with
|
||||||
|
remoteDomain: text('remote_domain').notNull(),
|
||||||
|
|
||||||
|
// Direction: 'push' (we sent) or 'pull' (we received)
|
||||||
|
direction: text('direction').notNull(),
|
||||||
|
|
||||||
|
// What was synced
|
||||||
|
nodesReceived: integer('nodes_received').default(0).notNull(),
|
||||||
|
nodesSent: integer('nodes_sent').default(0).notNull(),
|
||||||
|
handlesReceived: integer('handles_received').default(0).notNull(),
|
||||||
|
handlesSent: integer('handles_sent').default(0).notNull(),
|
||||||
|
|
||||||
|
// Result
|
||||||
|
success: boolean('success').notNull(),
|
||||||
|
errorMessage: text('error_message'),
|
||||||
|
durationMs: integer('duration_ms'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('swarm_sync_log_remote_idx').on(table.remoteDomain),
|
||||||
|
index('swarm_sync_log_created_idx').on(table.createdAt),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Next.js Instrumentation
|
||||||
|
*
|
||||||
|
* This file runs when the Next.js server starts.
|
||||||
|
* We use it to initialize background tasks like:
|
||||||
|
* - Swarm announcement (on startup)
|
||||||
|
* - Bot cron (every 1 minute)
|
||||||
|
* - Swarm gossip (every 5 minutes)
|
||||||
|
*
|
||||||
|
* This eliminates the need for a separate cron process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
// Only run on the server (not during build or in edge runtime)
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { startBackgroundTasks } = await import('@/lib/background/scheduler');
|
||||||
|
startBackgroundTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Background Task Scheduler
|
||||||
|
*
|
||||||
|
* Runs periodic tasks within the Next.js process:
|
||||||
|
* - Bot scheduling (every 1 minute)
|
||||||
|
* - Swarm gossip (every 5 minutes)
|
||||||
|
* - Swarm announcement (on startup)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { processScheduledPosts } from '@/lib/bots/scheduler';
|
||||||
|
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
||||||
|
import { runGossipRound } from '@/lib/swarm/gossip';
|
||||||
|
import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||||
|
import { getSwarmStats } from '@/lib/swarm/registry';
|
||||||
|
|
||||||
|
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||||
|
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready
|
||||||
|
|
||||||
|
let isStarted = false;
|
||||||
|
|
||||||
|
function log(category: string, message: string, data?: unknown) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
if (data) {
|
||||||
|
console.log(`[${timestamp}] [${category}] ${message}`, JSON.stringify(data, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`[${timestamp}] [${category}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBotTasks() {
|
||||||
|
try {
|
||||||
|
const scheduledResult = await processScheduledPosts();
|
||||||
|
const autonomousResult = await processAllAutonomousBots();
|
||||||
|
|
||||||
|
const posted = autonomousResult.filter(r => r.result.posted).length;
|
||||||
|
if (scheduledResult.processed > 0 || posted > 0) {
|
||||||
|
log('BOTS', `Processed ${scheduledResult.processed} scheduled, ${posted} autonomous posts`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('BOTS', `Error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSwarmGossip() {
|
||||||
|
try {
|
||||||
|
const result = await runGossipRound();
|
||||||
|
if (result.contacted > 0) {
|
||||||
|
log('SWARM', `Gossip: contacted ${result.contacted}, successful ${result.successful}, received ${result.totalNodesReceived} nodes`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('SWARM', `Gossip error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function announceToSwarm() {
|
||||||
|
try {
|
||||||
|
const result = await announceToSeeds();
|
||||||
|
log('SWARM', `Announced to seeds: ${result.successful.length} successful, ${result.failed.length} failed`);
|
||||||
|
|
||||||
|
const stats = await getSwarmStats();
|
||||||
|
log('SWARM', `Network: ${stats.activeNodes} active nodes, ${stats.totalUsers} users, ${stats.totalPosts} posts`);
|
||||||
|
} catch (error) {
|
||||||
|
log('SWARM', `Announcement error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBackgroundTasks() {
|
||||||
|
// Prevent double-start (Next.js can call register() multiple times in dev)
|
||||||
|
if (isStarted) return;
|
||||||
|
isStarted = true;
|
||||||
|
|
||||||
|
log('STARTUP', 'Background task scheduler starting...');
|
||||||
|
log('STARTUP', `Bot interval: ${BOT_INTERVAL_MS / 1000}s, Gossip interval: ${GOSSIP_INTERVAL_MS / 1000}s`);
|
||||||
|
|
||||||
|
// Wait for server to be fully ready before starting tasks
|
||||||
|
setTimeout(async () => {
|
||||||
|
log('STARTUP', 'Starting background tasks...');
|
||||||
|
|
||||||
|
// Announce to swarm on startup
|
||||||
|
await announceToSwarm();
|
||||||
|
|
||||||
|
// Run initial bot check
|
||||||
|
await runBotTasks();
|
||||||
|
|
||||||
|
// Schedule recurring tasks
|
||||||
|
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
||||||
|
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
|
||||||
|
|
||||||
|
// First gossip after 30s (let announcement propagate)
|
||||||
|
setTimeout(runSwarmGossip, 30 * 1000);
|
||||||
|
|
||||||
|
log('STARTUP', 'Background tasks running');
|
||||||
|
}, STARTUP_DELAY_MS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Discovery
|
||||||
|
*
|
||||||
|
* Handles node discovery and announcement in the swarm network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, nodes, users, posts } from '@/db';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
||||||
|
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build this node's announcement payload
|
||||||
|
*/
|
||||||
|
export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
|
||||||
|
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
let name = 'Synapsis Node';
|
||||||
|
let description: string | undefined;
|
||||||
|
let logoUrl: string | undefined;
|
||||||
|
let publicKey = '';
|
||||||
|
let userCount = 0;
|
||||||
|
let postCount = 0;
|
||||||
|
let isNsfw = false;
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
// Get node info
|
||||||
|
const node = await db.query.nodes.findFirst({
|
||||||
|
where: eq(nodes.domain, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
name = node.name;
|
||||||
|
description = node.description ?? undefined;
|
||||||
|
logoUrl = node.logoUrl ?? undefined;
|
||||||
|
publicKey = node.publicKey ?? '';
|
||||||
|
isNsfw = node.isNsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get counts
|
||||||
|
const userResult = await db.select({ count: sql<number>`count(*)` }).from(users);
|
||||||
|
const postResult = await db.select({ count: sql<number>`count(*)` }).from(posts);
|
||||||
|
|
||||||
|
userCount = Number(userResult[0]?.count ?? 0);
|
||||||
|
postCount = Number(postResult[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities: SwarmCapability[] = ['handles', 'gossip'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
logoUrl,
|
||||||
|
publicKey,
|
||||||
|
softwareVersion: '0.1.0', // TODO: Get from package.json
|
||||||
|
userCount,
|
||||||
|
postCount,
|
||||||
|
capabilities,
|
||||||
|
isNsfw,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce this node to a remote node
|
||||||
|
*/
|
||||||
|
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const announcement = await buildAnnouncement();
|
||||||
|
|
||||||
|
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
||||||
|
const url = `${baseUrl}/api/swarm/announce`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(announcement),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
await markNodeFailure(targetDomain);
|
||||||
|
return { success: false, error: `HTTP ${response.status}: ${error}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remote node should respond with their info
|
||||||
|
const remoteInfo = await response.json() as SwarmNodeInfo;
|
||||||
|
|
||||||
|
// Add/update the remote node in our registry
|
||||||
|
await upsertSwarmNode(remoteInfo, 'direct');
|
||||||
|
await markNodeSuccess(targetDomain);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
await markNodeFailure(targetDomain);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce to all seed nodes (bootstrap)
|
||||||
|
*/
|
||||||
|
export async function announceToSeeds(): Promise<{
|
||||||
|
successful: string[];
|
||||||
|
failed: { domain: string; error: string }[]
|
||||||
|
}> {
|
||||||
|
const seeds = await getSeedNodes();
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
// Don't announce to ourselves
|
||||||
|
const targetSeeds = seeds.filter(s => s !== ourDomain);
|
||||||
|
|
||||||
|
const successful: string[] = [];
|
||||||
|
const failed: { domain: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const seed of targetSeeds) {
|
||||||
|
const result = await announceToNode(seed);
|
||||||
|
if (result.success) {
|
||||||
|
successful.push(seed);
|
||||||
|
} else {
|
||||||
|
failed.push({ domain: seed, error: result.error || 'Unknown error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { successful, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch node info from a remote node
|
||||||
|
*/
|
||||||
|
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
||||||
|
try {
|
||||||
|
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
|
||||||
|
|
||||||
|
// Try the swarm endpoint first
|
||||||
|
let response = await fetch(`${baseUrl}/api/swarm/info`, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Fall back to standard node endpoint
|
||||||
|
response = await fetch(`${baseUrl}/api/node`, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: data.domain || domain,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
|
publicKey: data.publicKey,
|
||||||
|
softwareVersion: data.softwareVersion,
|
||||||
|
userCount: data.userCount,
|
||||||
|
postCount: data.postCount,
|
||||||
|
capabilities: data.capabilities,
|
||||||
|
isNsfw: data.isNsfw,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover a node and add it to the registry
|
||||||
|
*/
|
||||||
|
export async function discoverNode(
|
||||||
|
domain: string,
|
||||||
|
discoveredVia?: string
|
||||||
|
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
// Don't discover ourselves
|
||||||
|
if (domain === ourDomain) {
|
||||||
|
return { success: false, isNew: false, error: 'Cannot discover self' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await fetchNodeInfo(domain);
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return { success: false, isNew: false, error: 'Could not fetch node info' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await upsertSwarmNode(info, discoveredVia);
|
||||||
|
|
||||||
|
return { success: true, isNew: result.isNew };
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Gossip Protocol
|
||||||
|
*
|
||||||
|
* Implements epidemic-style gossip for node and handle propagation.
|
||||||
|
* Nodes periodically exchange their known nodes/handles with random peers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, handleRegistry } from '@/db';
|
||||||
|
import { desc, gt } from 'drizzle-orm';
|
||||||
|
import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types';
|
||||||
|
import { SWARM_CONFIG } from './types';
|
||||||
|
import {
|
||||||
|
getNodesForGossip,
|
||||||
|
getActiveSwarmNodes,
|
||||||
|
getNodesSince,
|
||||||
|
upsertSwarmNodes,
|
||||||
|
markNodeSuccess,
|
||||||
|
markNodeFailure,
|
||||||
|
logSync,
|
||||||
|
} from './registry';
|
||||||
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
import { buildAnnouncement } from './discovery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a gossip payload to send to another node
|
||||||
|
*/
|
||||||
|
export async function buildGossipPayload(since?: string): Promise<SwarmGossipPayload> {
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
// Get nodes to share
|
||||||
|
let nodes: SwarmNodeInfo[];
|
||||||
|
if (since) {
|
||||||
|
nodes = await getNodesSince(new Date(since), SWARM_CONFIG.maxNodesPerGossip);
|
||||||
|
} else {
|
||||||
|
nodes = await getActiveSwarmNodes(SWARM_CONFIG.maxNodesPerGossip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ourselves in the node list
|
||||||
|
const announcement = await buildAnnouncement();
|
||||||
|
const selfNode: SwarmNodeInfo = {
|
||||||
|
domain: announcement.domain,
|
||||||
|
name: announcement.name,
|
||||||
|
description: announcement.description,
|
||||||
|
logoUrl: announcement.logoUrl,
|
||||||
|
publicKey: announcement.publicKey,
|
||||||
|
softwareVersion: announcement.softwareVersion,
|
||||||
|
userCount: announcement.userCount,
|
||||||
|
postCount: announcement.postCount,
|
||||||
|
capabilities: announcement.capabilities,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get handles to share
|
||||||
|
let handles: SwarmGossipPayload['handles'] = [];
|
||||||
|
if (db) {
|
||||||
|
const sinceDate = since ? new Date(since) : undefined;
|
||||||
|
const handleEntries = await db.query.handleRegistry.findMany({
|
||||||
|
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
|
||||||
|
orderBy: [desc(handleRegistry.updatedAt)],
|
||||||
|
limit: SWARM_CONFIG.maxHandlesPerGossip,
|
||||||
|
});
|
||||||
|
|
||||||
|
handles = handleEntries.map(h => ({
|
||||||
|
handle: h.handle,
|
||||||
|
did: h.did,
|
||||||
|
nodeDomain: h.nodeDomain,
|
||||||
|
updatedAt: h.updatedAt?.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender: ourDomain,
|
||||||
|
nodes: [selfNode, ...nodes],
|
||||||
|
handles,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
since,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process incoming gossip and return our response
|
||||||
|
*/
|
||||||
|
export async function processGossip(
|
||||||
|
payload: SwarmGossipPayload
|
||||||
|
): Promise<SwarmGossipResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Process incoming nodes
|
||||||
|
const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender);
|
||||||
|
|
||||||
|
// Process incoming handles
|
||||||
|
let handlesResult = { added: 0, updated: 0 };
|
||||||
|
if (payload.handles && payload.handles.length > 0) {
|
||||||
|
handlesResult = await upsertHandleEntries(payload.handles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build our response with nodes/handles to share back
|
||||||
|
const responsePayload = await buildGossipPayload(payload.since);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: responsePayload.nodes,
|
||||||
|
handles: responsePayload.handles,
|
||||||
|
received: {
|
||||||
|
nodes: nodeResult.added + nodeResult.updated,
|
||||||
|
handles: handlesResult.added + handlesResult.updated,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send gossip to a specific node
|
||||||
|
*/
|
||||||
|
export async function gossipToNode(
|
||||||
|
targetDomain: string,
|
||||||
|
since?: string
|
||||||
|
): Promise<SwarmSyncResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await buildGossipPayload(since);
|
||||||
|
|
||||||
|
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
||||||
|
const url = `${baseUrl}/api/swarm/gossip`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = `HTTP ${response.status}`;
|
||||||
|
await markNodeFailure(targetDomain);
|
||||||
|
await logSync(targetDomain, 'push', {
|
||||||
|
success: false,
|
||||||
|
nodesReceived: 0,
|
||||||
|
nodesSent: payload.nodes.length,
|
||||||
|
handlesReceived: 0,
|
||||||
|
handlesSent: payload.handles?.length || 0,
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
nodesReceived: 0,
|
||||||
|
nodesSent: payload.nodes.length,
|
||||||
|
handlesReceived: 0,
|
||||||
|
handlesSent: payload.handles?.length || 0,
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gossipResponse = await response.json() as SwarmGossipResponse;
|
||||||
|
|
||||||
|
// Process the response (nodes and handles they sent back)
|
||||||
|
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
|
||||||
|
|
||||||
|
let handlesResult = { added: 0, updated: 0 };
|
||||||
|
if (gossipResponse.handles && gossipResponse.handles.length > 0) {
|
||||||
|
handlesResult = await upsertHandleEntries(gossipResponse.handles);
|
||||||
|
}
|
||||||
|
|
||||||
|
await markNodeSuccess(targetDomain);
|
||||||
|
|
||||||
|
const result: SwarmSyncResult = {
|
||||||
|
success: true,
|
||||||
|
nodesReceived: nodeResult.added + nodeResult.updated,
|
||||||
|
nodesSent: payload.nodes.length,
|
||||||
|
handlesReceived: handlesResult.added + handlesResult.updated,
|
||||||
|
handlesSent: payload.handles?.length || 0,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
await logSync(targetDomain, 'push', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
await markNodeFailure(targetDomain);
|
||||||
|
|
||||||
|
const result: SwarmSyncResult = {
|
||||||
|
success: false,
|
||||||
|
nodesReceived: 0,
|
||||||
|
nodesSent: 0,
|
||||||
|
handlesReceived: 0,
|
||||||
|
handlesSent: 0,
|
||||||
|
error: errorMsg,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
await logSync(targetDomain, 'push', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a gossip round - contact random nodes and exchange info
|
||||||
|
*/
|
||||||
|
export async function runGossipRound(): Promise<{
|
||||||
|
contacted: number;
|
||||||
|
successful: number;
|
||||||
|
totalNodesReceived: number;
|
||||||
|
totalHandlesReceived: number;
|
||||||
|
}> {
|
||||||
|
// Get random nodes to gossip with
|
||||||
|
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
|
||||||
|
|
||||||
|
let contacted = 0;
|
||||||
|
let successful = 0;
|
||||||
|
let totalNodesReceived = 0;
|
||||||
|
let totalHandlesReceived = 0;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
contacted++;
|
||||||
|
const result = await gossipToNode(target.domain);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successful++;
|
||||||
|
totalNodesReceived += result.nodesReceived;
|
||||||
|
totalHandlesReceived += result.handlesReceived;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacted,
|
||||||
|
successful,
|
||||||
|
totalNodesReceived,
|
||||||
|
totalHandlesReceived,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Module
|
||||||
|
*
|
||||||
|
* The Synapsis swarm is a decentralized network of nodes that discover
|
||||||
|
* and communicate with each other using a hybrid approach:
|
||||||
|
*
|
||||||
|
* 1. Seed nodes (like node.synapsis.social) provide initial bootstrap
|
||||||
|
* 2. Gossip protocol spreads node/handle information epidemically
|
||||||
|
* 3. Any node can discover the full network without central authority
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - On node startup: call announceToSeeds() to register with the network
|
||||||
|
* - Periodically: call runGossipRound() to exchange info with peers
|
||||||
|
* - On demand: call discoverNode() to add a specific node
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './registry';
|
||||||
|
export * from './discovery';
|
||||||
|
export * from './gossip';
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Registry
|
||||||
|
*
|
||||||
|
* Manages the local registry of known swarm nodes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
|
||||||
|
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
||||||
|
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
||||||
|
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a swarm node entry
|
||||||
|
*/
|
||||||
|
export async function upsertSwarmNode(
|
||||||
|
node: SwarmNodeInfo,
|
||||||
|
discoveredVia?: string
|
||||||
|
): Promise<{ isNew: boolean }> {
|
||||||
|
if (!db) {
|
||||||
|
return { isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.swarmNodes.findFirst({
|
||||||
|
where: eq(swarmNodes.domain, node.domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(swarmNodes).values({
|
||||||
|
domain: node.domain,
|
||||||
|
name: node.name,
|
||||||
|
description: node.description,
|
||||||
|
logoUrl: node.logoUrl,
|
||||||
|
publicKey: node.publicKey,
|
||||||
|
softwareVersion: node.softwareVersion,
|
||||||
|
userCount: node.userCount,
|
||||||
|
postCount: node.postCount,
|
||||||
|
isNsfw: node.isNsfw ?? false,
|
||||||
|
discoveredVia,
|
||||||
|
capabilities,
|
||||||
|
lastSeenAt: node.lastSeenAt ? new Date(node.lastSeenAt) : new Date(),
|
||||||
|
});
|
||||||
|
return { isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing node
|
||||||
|
await db.update(swarmNodes)
|
||||||
|
.set({
|
||||||
|
name: node.name ?? existing.name,
|
||||||
|
description: node.description ?? existing.description,
|
||||||
|
logoUrl: node.logoUrl ?? existing.logoUrl,
|
||||||
|
publicKey: node.publicKey ?? existing.publicKey,
|
||||||
|
softwareVersion: node.softwareVersion ?? existing.softwareVersion,
|
||||||
|
userCount: node.userCount ?? existing.userCount,
|
||||||
|
postCount: node.postCount ?? existing.postCount,
|
||||||
|
isNsfw: node.isNsfw ?? existing.isNsfw,
|
||||||
|
capabilities: capabilities ?? existing.capabilities,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(swarmNodes.domain, node.domain));
|
||||||
|
|
||||||
|
return { isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk upsert swarm nodes from gossip
|
||||||
|
*/
|
||||||
|
export async function upsertSwarmNodes(
|
||||||
|
nodes: SwarmNodeInfo[],
|
||||||
|
discoveredVia: string
|
||||||
|
): Promise<{ added: number; updated: number }> {
|
||||||
|
if (!db || nodes.length === 0) {
|
||||||
|
return { added: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
// Filter out our own domain
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
||||||
|
|
||||||
|
for (const node of filteredNodes) {
|
||||||
|
const result = await upsertSwarmNode(node, discoveredVia);
|
||||||
|
if (result.isNew) {
|
||||||
|
added++;
|
||||||
|
} else {
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active swarm nodes
|
||||||
|
*/
|
||||||
|
export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]> {
|
||||||
|
if (!db) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await db.query.swarmNodes.findMany({
|
||||||
|
where: eq(swarmNodes.isActive, true),
|
||||||
|
orderBy: [desc(swarmNodes.lastSeenAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes.map(nodeToInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nodes for gossip (random selection of active nodes)
|
||||||
|
*/
|
||||||
|
export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]> {
|
||||||
|
if (!db) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active nodes with decent trust scores, ordered randomly
|
||||||
|
const nodes = await db.query.swarmNodes.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(swarmNodes.isActive, true),
|
||||||
|
gt(swarmNodes.trustScore, 20)
|
||||||
|
),
|
||||||
|
orderBy: sql`RANDOM()`,
|
||||||
|
limit: count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes.map(nodeToInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nodes updated since a timestamp (for incremental sync)
|
||||||
|
*/
|
||||||
|
export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNodeInfo[]> {
|
||||||
|
if (!db) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await db.query.swarmNodes.findMany({
|
||||||
|
where: gt(swarmNodes.updatedAt, since),
|
||||||
|
orderBy: [desc(swarmNodes.updatedAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes.map(nodeToInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a node as having failed contact
|
||||||
|
*/
|
||||||
|
export async function markNodeFailure(domain: string): Promise<void> {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const node = await db.query.swarmNodes.findFirst({
|
||||||
|
where: eq(swarmNodes.domain, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const newFailures = node.consecutiveFailures + 1;
|
||||||
|
const newTrust = Math.max(
|
||||||
|
SWARM_CONFIG.minTrustScore,
|
||||||
|
node.trustScore + SWARM_CONFIG.trustScoreOnFailure
|
||||||
|
);
|
||||||
|
const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures;
|
||||||
|
|
||||||
|
await db.update(swarmNodes)
|
||||||
|
.set({
|
||||||
|
consecutiveFailures: newFailures,
|
||||||
|
trustScore: newTrust,
|
||||||
|
isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(swarmNodes.domain, domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a node as successfully contacted
|
||||||
|
*/
|
||||||
|
export async function markNodeSuccess(domain: string): Promise<void> {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const node = await db.query.swarmNodes.findFirst({
|
||||||
|
where: eq(swarmNodes.domain, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const newTrust = Math.min(
|
||||||
|
SWARM_CONFIG.maxTrustScore,
|
||||||
|
node.trustScore + SWARM_CONFIG.trustScoreOnSuccess
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.update(swarmNodes)
|
||||||
|
.set({
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
trustScore: newTrust,
|
||||||
|
isActive: true,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(swarmNodes.domain, domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a sync operation
|
||||||
|
*/
|
||||||
|
export async function logSync(
|
||||||
|
remoteDomain: string,
|
||||||
|
direction: 'push' | 'pull',
|
||||||
|
result: SwarmSyncResult
|
||||||
|
): Promise<void> {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
await db.insert(swarmSyncLog).values({
|
||||||
|
remoteDomain,
|
||||||
|
direction,
|
||||||
|
nodesReceived: result.nodesReceived,
|
||||||
|
nodesSent: result.nodesSent,
|
||||||
|
handlesReceived: result.handlesReceived,
|
||||||
|
handlesSent: result.handlesSent,
|
||||||
|
success: result.success,
|
||||||
|
errorMessage: result.error,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get seed nodes (with fallback to defaults)
|
||||||
|
*/
|
||||||
|
export async function getSeedNodes(): Promise<string[]> {
|
||||||
|
if (!db) {
|
||||||
|
return [...DEFAULT_SEED_NODES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seeds = await db.query.swarmSeeds.findMany({
|
||||||
|
where: eq(swarmSeeds.isEnabled, true),
|
||||||
|
orderBy: [swarmSeeds.priority],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seeds.length === 0) {
|
||||||
|
return [...DEFAULT_SEED_NODES];
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds.map(s => s.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a seed node
|
||||||
|
*/
|
||||||
|
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
await db.insert(swarmSeeds)
|
||||||
|
.values({ domain, priority })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: swarmSeeds.domain,
|
||||||
|
set: { priority, isEnabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get swarm statistics
|
||||||
|
*/
|
||||||
|
export async function getSwarmStats() {
|
||||||
|
if (!db) {
|
||||||
|
return {
|
||||||
|
totalNodes: 0,
|
||||||
|
activeNodes: 0,
|
||||||
|
totalUsers: 0,
|
||||||
|
totalPosts: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNodes = await db.query.swarmNodes.findMany();
|
||||||
|
const activeNodes = allNodes.filter(n => n.isActive);
|
||||||
|
|
||||||
|
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
|
||||||
|
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNodes: allNodes.length,
|
||||||
|
activeNodes: activeNodes.length,
|
||||||
|
totalUsers,
|
||||||
|
totalPosts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert DB node to SwarmNodeInfo
|
||||||
|
function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
|
||||||
|
return {
|
||||||
|
domain: node.domain,
|
||||||
|
name: node.name ?? undefined,
|
||||||
|
description: node.description ?? undefined,
|
||||||
|
logoUrl: node.logoUrl ?? undefined,
|
||||||
|
publicKey: node.publicKey ?? undefined,
|
||||||
|
softwareVersion: node.softwareVersion ?? undefined,
|
||||||
|
userCount: node.userCount ?? undefined,
|
||||||
|
postCount: node.postCount ?? undefined,
|
||||||
|
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
|
||||||
|
isNsfw: node.isNsfw,
|
||||||
|
lastSeenAt: node.lastSeenAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Timeline
|
||||||
|
*
|
||||||
|
* Fetches and aggregates posts from across the swarm network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getActiveSwarmNodes } from './registry';
|
||||||
|
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
|
||||||
|
|
||||||
|
interface TimelineResult {
|
||||||
|
posts: SwarmPost[];
|
||||||
|
sources: { domain: string; postCount: number; isNsfw?: boolean; error?: string }[];
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineOptions {
|
||||||
|
includeNsfw?: boolean; // Whether to include NSFW content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch timeline from a single node
|
||||||
|
*/
|
||||||
|
async function fetchNodeTimeline(
|
||||||
|
domain: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
|
||||||
|
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { posts: [], error: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return { posts: data.posts || [], nodeIsNsfw: data.nodeIsNsfw };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return { posts: [], error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch aggregated timeline from the swarm
|
||||||
|
*
|
||||||
|
* Queries multiple nodes in parallel and merges results.
|
||||||
|
* Filters out NSFW content unless explicitly requested.
|
||||||
|
*/
|
||||||
|
export async function fetchSwarmTimeline(
|
||||||
|
maxNodes: number = 10,
|
||||||
|
postsPerNode: number = 10,
|
||||||
|
options: TimelineOptions = {}
|
||||||
|
): Promise<TimelineResult> {
|
||||||
|
const { includeNsfw = false } = options;
|
||||||
|
|
||||||
|
// Get active nodes to query
|
||||||
|
const nodes = await getActiveSwarmNodes(maxNodes);
|
||||||
|
|
||||||
|
// Always include our own posts
|
||||||
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
|
|
||||||
|
// Filter out NSFW nodes if not including NSFW content
|
||||||
|
const eligibleNodes = includeNsfw
|
||||||
|
? nodes
|
||||||
|
: nodes.filter(n => !n.isNsfw);
|
||||||
|
|
||||||
|
const nodesToQuery = [
|
||||||
|
ourDomain,
|
||||||
|
...eligibleNodes.map(n => n.domain).filter(d => d !== ourDomain)
|
||||||
|
].slice(0, maxNodes);
|
||||||
|
|
||||||
|
// Fetch from all nodes in parallel
|
||||||
|
const results = await Promise.all(
|
||||||
|
nodesToQuery.map(async (domain) => {
|
||||||
|
const result = await fetchNodeTimeline(domain, postsPerNode);
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect all posts and track sources
|
||||||
|
const allPosts: SwarmPost[] = [];
|
||||||
|
const sources: TimelineResult['sources'] = [];
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
sources.push({
|
||||||
|
domain: result.domain,
|
||||||
|
postCount: result.posts.length,
|
||||||
|
isNsfw: result.nodeIsNsfw,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter NSFW posts if not including NSFW
|
||||||
|
const filteredPosts = includeNsfw
|
||||||
|
? result.posts
|
||||||
|
: result.posts.filter(p => !p.isNsfw);
|
||||||
|
|
||||||
|
allPosts.push(...filteredPosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending and dedupe by id
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const uniquePosts = allPosts
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.filter(post => {
|
||||||
|
const key = `${post.nodeDomain}:${post.id}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: uniquePosts,
|
||||||
|
sources,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Swarm Types
|
||||||
|
*
|
||||||
|
* Type definitions for the Synapsis swarm network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SwarmNodeInfo {
|
||||||
|
domain: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
softwareVersion?: string;
|
||||||
|
userCount?: number;
|
||||||
|
postCount?: number;
|
||||||
|
capabilities?: SwarmCapability[];
|
||||||
|
isNsfw?: boolean;
|
||||||
|
lastSeenAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search';
|
||||||
|
|
||||||
|
export interface SwarmAnnouncement {
|
||||||
|
domain: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
publicKey: string;
|
||||||
|
softwareVersion: string;
|
||||||
|
userCount: number;
|
||||||
|
postCount: number;
|
||||||
|
capabilities: SwarmCapability[];
|
||||||
|
isNsfw: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
signature?: string; // Signed with node's private key
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmGossipPayload {
|
||||||
|
// The node sending this gossip
|
||||||
|
sender: string;
|
||||||
|
|
||||||
|
// Nodes this sender knows about
|
||||||
|
nodes: SwarmNodeInfo[];
|
||||||
|
|
||||||
|
// Optional: handles to sync (piggyback on gossip)
|
||||||
|
handles?: {
|
||||||
|
handle: string;
|
||||||
|
did: string;
|
||||||
|
nodeDomain: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Timestamp for freshness
|
||||||
|
timestamp: string;
|
||||||
|
|
||||||
|
// Since parameter for incremental sync
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmGossipResponse {
|
||||||
|
// Nodes we're sharing back
|
||||||
|
nodes: SwarmNodeInfo[];
|
||||||
|
|
||||||
|
// Handles we're sharing back
|
||||||
|
handles?: {
|
||||||
|
handle: string;
|
||||||
|
did: string;
|
||||||
|
nodeDomain: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Stats about what we received
|
||||||
|
received: {
|
||||||
|
nodes: number;
|
||||||
|
handles: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
nodesReceived: number;
|
||||||
|
nodesSent: number;
|
||||||
|
handlesReceived: number;
|
||||||
|
handlesSent: number;
|
||||||
|
error?: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmStats {
|
||||||
|
totalNodes: number;
|
||||||
|
activeNodes: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalPosts: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default seed nodes for bootstrapping
|
||||||
|
export const DEFAULT_SEED_NODES = [
|
||||||
|
'node.synapsis.social',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Swarm configuration
|
||||||
|
export const SWARM_CONFIG = {
|
||||||
|
// How often to run gossip (in ms)
|
||||||
|
gossipIntervalMs: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|
||||||
|
// How many nodes to gossip with per round
|
||||||
|
gossipFanout: 3,
|
||||||
|
|
||||||
|
// Max nodes to include in a single gossip message
|
||||||
|
maxNodesPerGossip: 100,
|
||||||
|
|
||||||
|
// Max handles to include in a single gossip message
|
||||||
|
maxHandlesPerGossip: 500,
|
||||||
|
|
||||||
|
// How long before a node is considered inactive
|
||||||
|
inactiveThresholdMs: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
|
||||||
|
// How many consecutive failures before marking inactive
|
||||||
|
maxConsecutiveFailures: 5,
|
||||||
|
|
||||||
|
// Trust score adjustments
|
||||||
|
trustScoreOnSuccess: 1,
|
||||||
|
trustScoreOnFailure: -5,
|
||||||
|
minTrustScore: 0,
|
||||||
|
maxTrustScore: 100,
|
||||||
|
defaultTrustScore: 50,
|
||||||
|
} as const;
|
||||||
@@ -61,4 +61,5 @@ export interface Post {
|
|||||||
handle: string;
|
handle: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
nodeDomain?: string | null; // Domain of the node this post came from (for swarm posts)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user