Add docker publish flow, node blocklist & API updates
Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
@@ -387,23 +387,6 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const formatTimestamp = (value?: string | null) => {
|
||||
if (!value) return 'Never';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const updateStatusLabel = (() => {
|
||||
if (loadingUpdateStatus) return 'Checking...';
|
||||
if (!updateStatus) return 'Unavailable';
|
||||
if (!updateStatus.updater.available) return updateStatus.updater.message || 'Updater unavailable';
|
||||
if (updateStatus.updater.status === 'updating') return updateStatus.updater.message || 'Update in progress';
|
||||
if (updateStatus.updater.status === 'error') return updateStatus.updater.message || 'Last update failed';
|
||||
if (updateStatus.updateAvailable) return 'Update available';
|
||||
return 'Up to date';
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header style={{
|
||||
@@ -783,51 +766,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
|
||||
<div>
|
||||
<strong>Current build:</strong>{' '}
|
||||
{updateStatus?.current?.version || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Latest build:</strong>{' '}
|
||||
{updateStatus?.latest?.version || 'Unavailable'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
{updateStatusLabel}
|
||||
</div>
|
||||
{updateStatus?.updater.lastStartedAt && (
|
||||
<div>
|
||||
<strong>Last started:</strong>{' '}
|
||||
{formatTimestamp(updateStatus.updater.lastStartedAt)}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.lastFinishedAt && (
|
||||
<div>
|
||||
<strong>Last finished:</strong>{' '}
|
||||
{formatTimestamp(updateStatus.updater.lastFinishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{typeof updateStatus?.updater.lastExitCode === 'number' && (
|
||||
<div>
|
||||
<strong>Last exit code:</strong>{' '}
|
||||
{updateStatus.updater.lastExitCode}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.trigger && (
|
||||
<div>
|
||||
<strong>Last trigger:</strong>{' '}
|
||||
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.lastError && (
|
||||
<div style={{ color: 'var(--danger)' }}>
|
||||
<strong>Last error:</strong>{' '}
|
||||
{updateStatus.updater.lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!updateStatus?.updater.available && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, swarmNodes } from '@/db';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { normalizeNodeDomain, unblockNode, upsertBlockedNode } from '@/lib/swarm/node-blocklist';
|
||||
|
||||
const mutateNodeSchema = z.object({
|
||||
action: z.enum(['block', 'unblock']),
|
||||
domain: z.string().min(1),
|
||||
reason: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const nodes = await db.query.swarmNodes.findMany({
|
||||
orderBy: [desc(swarmNodes.isBlocked), desc(swarmNodes.blockedAt), desc(swarmNodes.lastSeenAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
domain: node.domain,
|
||||
name: node.name,
|
||||
description: node.description,
|
||||
isActive: node.isActive,
|
||||
isBlocked: node.isBlocked,
|
||||
blockReason: node.blockReason,
|
||||
blockedAt: node.blockedAt,
|
||||
lastSeenAt: node.lastSeenAt,
|
||||
trustScore: node.trustScore,
|
||||
isNsfw: node.isNsfw,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin get nodes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to load nodes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const data = mutateNodeSchema.parse(body);
|
||||
const domain = normalizeNodeDomain(data.domain);
|
||||
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000');
|
||||
|
||||
if (domain === localDomain) {
|
||||
return NextResponse.json({ error: 'Cannot block this node itself' }, { status: 400 });
|
||||
}
|
||||
|
||||
const node = data.action === 'block'
|
||||
? await upsertBlockedNode(domain, data.reason || null)
|
||||
: await unblockNode(domain);
|
||||
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ node });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Admin update nodes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update node blocklist' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { destroySession } from '@/lib/auth';
|
||||
import { destroySession, getSession } from '@/lib/auth';
|
||||
import { clearStorageSession } from '@/lib/storage/session';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function POST() {
|
||||
const logoutSchema = z.object({
|
||||
userId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await clearStorageSession();
|
||||
await destroySession();
|
||||
const currentSession = await getSession();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const data = logoutSchema.parse(body);
|
||||
|
||||
const targetUserId = data.userId ?? currentSession?.user.id;
|
||||
|
||||
if (targetUserId) {
|
||||
await clearStorageSession(targetUserId);
|
||||
}
|
||||
|
||||
await destroySession(targetUserId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { getSession, getSessionAccounts } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
@@ -22,9 +22,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const accounts = await getSessionAccounts();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ user: null });
|
||||
return NextResponse.json({ user: null, accounts: [] });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -40,10 +41,11 @@ export async function GET() {
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
},
|
||||
accounts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Session check error:', error);
|
||||
return NextResponse.json({ user: null });
|
||||
return NextResponse.json({ user: null, accounts: [] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { switchSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const switchSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = switchSchema.parse(body);
|
||||
const session = await switchSession(data.userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
website: session.user.website,
|
||||
dmPrivacy: session.user.dmPrivacy,
|
||||
did: session.user.did,
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Switch session error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to switch account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ type RouteContext = { params: Promise<{ id: string }> };
|
||||
// Schema for setting API key
|
||||
const setApiKeySchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
|
||||
provider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,8 +34,9 @@ const updateBotSchema = z.object({
|
||||
maxTokens: z.number().int().min(1).max(100000),
|
||||
responseStyle: z.string().optional(),
|
||||
}).optional(),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
|
||||
llmModel: z.string().min(1).optional(),
|
||||
llmEndpoint: z.string().url().optional().nullable(),
|
||||
llmApiKey: z.string().min(1).optional(),
|
||||
schedule: z.object({
|
||||
type: z.enum(['interval', 'times', 'cron']),
|
||||
@@ -91,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
@@ -162,6 +164,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
|
||||
if (data.personality !== undefined) updateInput.personality = data.personality;
|
||||
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
|
||||
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
|
||||
if (data.llmEndpoint !== undefined) updateInput.llmEndpoint = data.llmEndpoint;
|
||||
if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
|
||||
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
|
||||
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
|
||||
@@ -183,6 +186,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
|
||||
personalityConfig: updatedBot.personalityConfig,
|
||||
llmProvider: updatedBot.llmProvider,
|
||||
llmModel: updatedBot.llmModel,
|
||||
llmEndpoint: updatedBot.llmEndpoint,
|
||||
scheduleConfig: updatedBot.scheduleConfig,
|
||||
autonomousMode: updatedBot.autonomousMode,
|
||||
isActive: updatedBot.isActive,
|
||||
|
||||
@@ -33,8 +33,9 @@ const createBotSchema = z.object({
|
||||
maxTokens: z.number().int().min(1).max(100000),
|
||||
responseStyle: z.string().optional(),
|
||||
}),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']),
|
||||
llmModel: z.string().min(1),
|
||||
llmEndpoint: z.string().url().optional(),
|
||||
llmApiKey: z.string().min(1),
|
||||
schedule: z.object({
|
||||
type: z.enum(['interval', 'times', 'cron']),
|
||||
@@ -96,6 +97,7 @@ export async function POST(request: Request) {
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
llmModel: data.llmModel,
|
||||
llmEndpoint: data.llmEndpoint,
|
||||
llmApiKey: data.llmApiKey,
|
||||
schedule: data.schedule,
|
||||
autonomousMode: data.autonomousMode,
|
||||
@@ -114,6 +116,7 @@ export async function POST(request: Request) {
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
@@ -189,6 +192,7 @@ export async function GET() {
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-sign
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||
import { z } from 'zod';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||
|
||||
const signedChatActionSchema = z.object({
|
||||
action: z.string().min(1),
|
||||
@@ -76,6 +77,11 @@ export async function POST(request: NextRequest) {
|
||||
let fullSenderHandle: string | null = null;
|
||||
|
||||
if (swarmSignature && sourceDomain && body.userAction) {
|
||||
const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
|
||||
if (await isNodeBlocked(normalizedSourceDomain)) {
|
||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Federated envelope format - validate and verify node signature
|
||||
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
||||
if (!envelopeValidation.success) {
|
||||
@@ -117,6 +123,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Use full handle if provided in envelope, otherwise fall back to signed handle
|
||||
const senderHandle = fullSenderHandle || handle;
|
||||
const senderDomainFromHandle = senderHandle.includes('@')
|
||||
? normalizeNodeDomain(senderHandle.split('@').pop() || '')
|
||||
: null;
|
||||
if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
|
||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
||||
}
|
||||
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
|
||||
|
||||
// 1. Resolve Sender Public Key
|
||||
@@ -134,21 +146,25 @@ export async function POST(request: NextRequest) {
|
||||
// Derive domain from full sender handle if possible
|
||||
if (senderHandle.includes('@')) {
|
||||
const parts = senderHandle.split('@');
|
||||
senderNodeDomain = parts[parts.length - 1];
|
||||
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
|
||||
} else {
|
||||
// Try to get from header first
|
||||
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
|
||||
if (sourceDomainHeader) {
|
||||
senderNodeDomain = sourceDomainHeader;
|
||||
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
|
||||
} else {
|
||||
// Try handle registry (though we likely don't have it if we don't have the user)
|
||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.did, did)
|
||||
});
|
||||
if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
|
||||
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
|
||||
}
|
||||
}
|
||||
|
||||
if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
|
||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (senderNodeDomain) {
|
||||
try {
|
||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { createSignedPayload } from '@/lib/swarm/signature';
|
||||
import { federatedHandleSchema } from '@/lib/utils/federation';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||
|
||||
const chatSendSchema = z.object({
|
||||
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
|
||||
@@ -167,6 +168,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
targetDomain = normalizeNodeDomain(targetDomain);
|
||||
if (await isNodeBlocked(targetDomain)) {
|
||||
return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
|
||||
}
|
||||
|
||||
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
|
||||
|
||||
// 2. Send to Remote Node (Forward the Signed Action)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
|
||||
import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
|
||||
|
||||
/**
|
||||
* Check if a URL is from Reddit.
|
||||
*/
|
||||
function isRedditUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch preview for Reddit URLs using their oEmbed API.
|
||||
*/
|
||||
async function fetchRedditPreview(url: string): Promise<{
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract title - try title field first, then parse from HTML
|
||||
let title = data.title || null;
|
||||
if (!title && data.html) {
|
||||
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
|
||||
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Build description from subreddit info
|
||||
let description = null;
|
||||
if (data.author_name) {
|
||||
description = `Posted by ${data.author_name}`;
|
||||
} else if (data.html) {
|
||||
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
|
||||
if (subredditMatch) {
|
||||
description = `r/${subredditMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: data.thumbnail_url || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData {
|
||||
return {
|
||||
url,
|
||||
title: title || url,
|
||||
description: description || null,
|
||||
image: image || null,
|
||||
type: image ? 'image' : 'card',
|
||||
videoUrl: null,
|
||||
media: image ? [{ url: image }] : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -75,68 +33,30 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
// Use Reddit-specific handler
|
||||
if (isRedditUrl(url)) {
|
||||
const preview = await fetchRedditPreview(url);
|
||||
const preview = await fetchRedditRichPreview(url);
|
||||
if (preview) {
|
||||
return NextResponse.json(preview);
|
||||
}
|
||||
// Fall back to URL-only response if oEmbed fails
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: 'Reddit',
|
||||
description: null,
|
||||
image: null,
|
||||
});
|
||||
|
||||
return NextResponse.json(buildBasicPreview(url, 'Reddit'));
|
||||
}
|
||||
|
||||
// Generic OG tag scraping for other sites
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (fetchError) {
|
||||
console.warn(`Fetch failed for URL: ${url}`, fetchError);
|
||||
const preview = await fetchGenericLinkPreview(url);
|
||||
if (!preview) {
|
||||
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const getMeta = (property: string) => {
|
||||
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
|
||||
const match = html.match(regex);
|
||||
if (match) return match[1];
|
||||
|
||||
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
|
||||
const matchRev = html.match(regexRev);
|
||||
return matchRev ? matchRev[1] : null;
|
||||
};
|
||||
|
||||
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
|
||||
const description = getMeta('description');
|
||||
const image = getMeta('image');
|
||||
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: title?.trim() || url,
|
||||
description: description?.trim() || null,
|
||||
image: image?.trim() || null,
|
||||
});
|
||||
return NextResponse.json(buildBasicPreview(
|
||||
preview.url,
|
||||
preview.title?.trim() || url,
|
||||
preview.description?.trim() || null,
|
||||
preview.image?.trim() || null,
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Link preview error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
|
||||
|
||||
@@ -99,6 +99,15 @@ export async function GET(request: Request) {
|
||||
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
|
||||
nodeDomain: row.actorNodeDomain,
|
||||
},
|
||||
target: row.targetHandle ? {
|
||||
handle: row.targetNodeDomain
|
||||
? `${row.targetHandle}@${row.targetNodeDomain}`
|
||||
: row.targetHandle,
|
||||
displayName: row.targetDisplayName,
|
||||
avatarUrl: row.targetAvatarUrl,
|
||||
nodeDomain: row.targetNodeDomain,
|
||||
isBot: row.targetIsBot,
|
||||
} : null,
|
||||
post: row.postId ? {
|
||||
id: row.postId,
|
||||
content: row.postContent,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,6 +16,25 @@ const postIdSchema = z.union([
|
||||
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
|
||||
]);
|
||||
|
||||
function isSignedActionPayload(payload: unknown): payload is SignedAction {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const value = payload as Record<string, unknown>;
|
||||
return typeof value.action === 'string'
|
||||
&& typeof value.did === 'string'
|
||||
&& typeof value.handle === 'string'
|
||||
&& typeof value.ts === 'number'
|
||||
&& typeof value.nonce === 'string'
|
||||
&& typeof value.sig === 'string'
|
||||
&& typeof value.data === 'object'
|
||||
&& value.data !== null;
|
||||
}
|
||||
|
||||
async function readOptionalJson(request: Request) {
|
||||
const rawBody = await request.text();
|
||||
if (!rawBody.trim()) return null;
|
||||
return JSON.parse(rawBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
@@ -71,6 +93,9 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
linkPreviewType: post.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
|
||||
mediaJson: post.media ? JSON.stringify(post.media) : null,
|
||||
};
|
||||
} catch {
|
||||
@@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
|
||||
// Like a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
const body = await readOptionalJson(request);
|
||||
const { id: paramId } = await context.params;
|
||||
const decodedParamId = decodeURIComponent(paramId);
|
||||
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
const user = isSignedActionPayload(body)
|
||||
? await requireSignedAction(body)
|
||||
: await requireAuth();
|
||||
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
|
||||
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
|
||||
}
|
||||
|
||||
const decodedId = decodedParamId;
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
@@ -183,6 +212,10 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, post.userId),
|
||||
});
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
@@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorNodeDomain: null, // Local user
|
||||
postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
|
||||
type: 'like',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, post.userId),
|
||||
});
|
||||
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: postAuthor.botOwnerId,
|
||||
@@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorNodeDomain: null,
|
||||
postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(postAuthor),
|
||||
type: 'like',
|
||||
});
|
||||
}
|
||||
@@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
// Unlike a post
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
const body = await readOptionalJson(request);
|
||||
const { id: paramId } = await context.params;
|
||||
const decodedParamId = decodeURIComponent(paramId);
|
||||
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
const user = isSignedActionPayload(body)
|
||||
? await requireSignedAction(body)
|
||||
: await requireAuth();
|
||||
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
|
||||
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
|
||||
}
|
||||
|
||||
const decodedId = decodedParamId;
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { db, posts, users, notifications, userSwarmReposts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null {
|
||||
return apId.substring(lastColonIndex + 1);
|
||||
}
|
||||
|
||||
async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const post = data.post;
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
authorHandle: post.author?.handle || 'unknown',
|
||||
authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown',
|
||||
authorAvatarUrl: post.author?.avatarUrl || null,
|
||||
content: post.content || '',
|
||||
postCreatedAt: new Date(post.createdAt || new Date().toISOString()),
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
linkPreviewType: post.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
|
||||
mediaJson: post.media ? JSON.stringify(post.media) : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Repost a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
@@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingRepost = await db.query.userSwarmReposts.findFirst({
|
||||
where: and(
|
||||
eq(userSwarmReposts.userId, user.id),
|
||||
eq(userSwarmReposts.nodeDomain, targetDomain),
|
||||
eq(userSwarmReposts.originalPostId, originalPostId),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRepost) {
|
||||
return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Deliver repost directly to the origin node
|
||||
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
|
||||
|
||||
@@ -83,6 +138,27 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 });
|
||||
}
|
||||
|
||||
const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId);
|
||||
if (snapshot) {
|
||||
await db.insert(userSwarmReposts).values({
|
||||
userId: user.id,
|
||||
nodeDomain: targetDomain,
|
||||
originalPostId,
|
||||
...snapshot,
|
||||
repostedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [userSwarmReposts.userId, userSwarmReposts.nodeDomain, userSwarmReposts.originalPostId],
|
||||
set: {
|
||||
...snapshot,
|
||||
repostedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`);
|
||||
return NextResponse.json({ success: true, reposted: true });
|
||||
}
|
||||
@@ -133,6 +209,10 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, originalPost.userId),
|
||||
});
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: originalPost.userId,
|
||||
@@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorNodeDomain: null, // Local user
|
||||
postId,
|
||||
postContent: originalPost.content?.slice(0, 200) || null,
|
||||
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
|
||||
type: 'repost',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, originalPost.userId),
|
||||
});
|
||||
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: postAuthor.botOwnerId,
|
||||
@@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorNodeDomain: null,
|
||||
postId,
|
||||
postContent: originalPost.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(postAuthor),
|
||||
type: 'repost',
|
||||
});
|
||||
}
|
||||
@@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingRepost = await db.query.userSwarmReposts.findFirst({
|
||||
where: and(
|
||||
eq(userSwarmReposts.userId, user.id),
|
||||
eq(userSwarmReposts.nodeDomain, targetDomain),
|
||||
eq(userSwarmReposts.originalPostId, originalPostId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingRepost) {
|
||||
return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Deliver unrepost directly to the origin node
|
||||
const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions');
|
||||
|
||||
@@ -254,6 +345,16 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 });
|
||||
}
|
||||
|
||||
await db.delete(userSwarmReposts).where(and(
|
||||
eq(userSwarmReposts.userId, user.id),
|
||||
eq(userSwarmReposts.nodeDomain, targetDomain),
|
||||
eq(userSwarmReposts.originalPostId, originalPostId),
|
||||
));
|
||||
|
||||
await db.update(users)
|
||||
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
|
||||
return NextResponse.json({ success: true, reposted: false });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remotePosts } from '@/db';
|
||||
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
// Schema for local post ID (UUID)
|
||||
const localPostIdSchema = z.string().uuid('Invalid post ID format');
|
||||
@@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex(
|
||||
// Combined schema that accepts either format
|
||||
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const postDetailRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function mapSwarmDetailPost(post: any, fallbackDomain: string): any {
|
||||
const effectiveDomain = post.nodeDomain || fallbackDomain;
|
||||
const rawId = post.originalPostId || post.id;
|
||||
|
||||
return {
|
||||
id: post.id?.startsWith('swarm:') ? post.id : `swarm:${effectiveDomain}:${rawId}`,
|
||||
originalPostId: rawId,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
isSwarm: true,
|
||||
nodeDomain: effectiveDomain,
|
||||
repostOfId: post.repostOf
|
||||
? (post.repostOf.id?.startsWith('swarm:')
|
||||
? post.repostOf.id
|
||||
: `swarm:${post.repostOf.nodeDomain || effectiveDomain}:${post.repostOf.originalPostId || post.repostOf.id}`)
|
||||
: (post.repostOfId ? `swarm:${effectiveDomain}:${post.repostOfId}` : null),
|
||||
repostOf: post.repostOf ? mapSwarmDetailPost(post.repostOf, post.repostOf.nodeDomain || effectiveDomain) : null,
|
||||
author: post.author ? {
|
||||
id: `swarm:${effectiveDomain}:${post.author.handle}`,
|
||||
handle: post.author.handle.includes('@') ? post.author.handle : `${post.author.handle}@${effectiveDomain}`,
|
||||
displayName: post.author.displayName,
|
||||
avatarUrl: post.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
nodeDomain: effectiveDomain,
|
||||
} : null,
|
||||
media: post.media?.map((m: any, idx: number) => ({
|
||||
id: m.id || `swarm:${effectiveDomain}:${rawId}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl,
|
||||
linkPreviewTitle: post.linkPreviewTitle,
|
||||
linkPreviewDescription: post.linkPreviewDescription,
|
||||
linkPreviewImage: post.linkPreviewImage,
|
||||
linkPreviewType: post.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||
linkPreviewMedia: post.linkPreviewMedia || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -47,67 +111,25 @@ export async function GET(
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
// Transform to match expected format
|
||||
mainPost = {
|
||||
id: id,
|
||||
originalPostId: originalPostId,
|
||||
content: data.post.content,
|
||||
createdAt: data.post.createdAt,
|
||||
likesCount: data.post.likesCount || 0,
|
||||
repostsCount: data.post.repostsCount || 0,
|
||||
repliesCount: data.post.repliesCount || 0,
|
||||
isSwarm: true,
|
||||
mainPost = mapSwarmDetailPost({
|
||||
...data.post,
|
||||
id,
|
||||
originalPostId,
|
||||
nodeDomain: originDomain,
|
||||
author: {
|
||||
id: `swarm:${originDomain}:${data.post.author.handle}`,
|
||||
handle: data.post.author.handle,
|
||||
displayName: data.post.author.displayName,
|
||||
avatarUrl: data.post.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
nodeDomain: originDomain,
|
||||
},
|
||||
media: data.post.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${originDomain}:${originalPostId}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
linkPreviewUrl: data.post.linkPreviewUrl,
|
||||
linkPreviewTitle: data.post.linkPreviewTitle,
|
||||
linkPreviewDescription: data.post.linkPreviewDescription,
|
||||
linkPreviewImage: data.post.linkPreviewImage,
|
||||
};
|
||||
}, originDomain);
|
||||
|
||||
// Transform replies from the origin node
|
||||
replyPosts = (data.replies || []).map((r: any) => ({
|
||||
id: `swarm:${originDomain}:${r.id}`,
|
||||
originalPostId: r.id,
|
||||
content: r.content,
|
||||
createdAt: r.createdAt,
|
||||
likesCount: r.likesCount || 0,
|
||||
repostsCount: r.repostsCount || 0,
|
||||
repliesCount: r.repliesCount || 0,
|
||||
isSwarm: true,
|
||||
replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({
|
||||
...reply,
|
||||
nodeDomain: originDomain,
|
||||
author: {
|
||||
id: `swarm:${originDomain}:${r.author.handle}`,
|
||||
handle: r.author.handle,
|
||||
displayName: r.author.displayName,
|
||||
avatarUrl: r.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
nodeDomain: originDomain,
|
||||
},
|
||||
media: r.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${originDomain}:${r.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
}));
|
||||
}, originDomain));
|
||||
|
||||
mainPost.repliesCount = replyPosts.length;
|
||||
|
||||
// Check if current user has liked this post
|
||||
try {
|
||||
const { requireAuth } = await import('@/lib/auth');
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
const viewer = await requireAuth();
|
||||
|
||||
const likeCheckRes = await fetch(
|
||||
@@ -119,6 +141,15 @@ export async function GET(
|
||||
const likeData = await likeCheckRes.json();
|
||||
mainPost.isLiked = likeData.isLiked;
|
||||
}
|
||||
|
||||
const repostedIds = await getViewerSwarmRepostedPostIds([
|
||||
{
|
||||
id,
|
||||
nodeDomain: originDomain,
|
||||
originalPostId,
|
||||
},
|
||||
], viewer.id);
|
||||
mainPost.isReposted = repostedIds.has(id);
|
||||
} catch {
|
||||
// Not logged in or timeout
|
||||
}
|
||||
@@ -138,13 +169,7 @@ export async function GET(
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
with: postDetailRelations,
|
||||
});
|
||||
|
||||
if (post) {
|
||||
@@ -155,10 +180,7 @@ export async function GET(
|
||||
eq(posts.replyToId, id),
|
||||
eq(posts.isRemoved, false)
|
||||
),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
},
|
||||
with: postDetailRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
@@ -235,6 +257,9 @@ export async function GET(
|
||||
linkPreviewTitle: cached.linkPreviewTitle,
|
||||
linkPreviewDescription: cached.linkPreviewDescription,
|
||||
linkPreviewImage: cached.linkPreviewImage,
|
||||
linkPreviewType: cached.linkPreviewType,
|
||||
linkPreviewVideoUrl: cached.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null,
|
||||
isLiked: false,
|
||||
isReposted: false,
|
||||
};
|
||||
|
||||
+293
-104
@@ -1,10 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
const POST_MAX_LENGTH = 600;
|
||||
const CURATION_WINDOW_HOURS = 72;
|
||||
@@ -17,6 +19,138 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||
return and(...filtered);
|
||||
};
|
||||
|
||||
type FeedPostWithChildren = {
|
||||
id: string;
|
||||
repostOf?: FeedPostWithChildren | null;
|
||||
replyTo?: FeedPostWithChildren | null;
|
||||
isLiked?: boolean;
|
||||
isReposted?: boolean;
|
||||
nodeDomain?: string | null;
|
||||
originalPostId?: string | null;
|
||||
};
|
||||
|
||||
function mapUserSwarmRepostToFeedPost(
|
||||
row: typeof userSwarmReposts.$inferSelect,
|
||||
author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'>
|
||||
): FeedPostWithChildren {
|
||||
const remoteAuthorHandle = row.authorHandle.includes('@')
|
||||
? row.authorHandle
|
||||
: `${row.authorHandle}@${row.nodeDomain}`;
|
||||
const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
|
||||
|
||||
return {
|
||||
id: `swarm-repost:${row.id}`,
|
||||
content: '',
|
||||
createdAt: row.repostedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: {
|
||||
id: author.id,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
avatarUrl: author.avatarUrl,
|
||||
isBot: author.isBot,
|
||||
},
|
||||
repostOfId: remoteOriginalId,
|
||||
repostOf: {
|
||||
id: remoteOriginalId,
|
||||
originalPostId: row.originalPostId,
|
||||
content: row.content,
|
||||
createdAt: row.postCreatedAt.toISOString(),
|
||||
likesCount: row.likesCount,
|
||||
repostsCount: row.repostsCount,
|
||||
repliesCount: row.repliesCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
author: {
|
||||
id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
|
||||
handle: remoteAuthorHandle,
|
||||
displayName: row.authorDisplayName || row.authorHandle,
|
||||
avatarUrl: row.authorAvatarUrl,
|
||||
isRemote: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
},
|
||||
media: row.mediaJson ? JSON.parse(row.mediaJson) : [],
|
||||
linkPreviewUrl: row.linkPreviewUrl,
|
||||
linkPreviewTitle: row.linkPreviewTitle,
|
||||
linkPreviewDescription: row.linkPreviewDescription,
|
||||
linkPreviewImage: row.linkPreviewImage,
|
||||
linkPreviewType: row.linkPreviewType,
|
||||
linkPreviewVideoUrl: row.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function getMixedFeedCursorDate(cursor: string | null) {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cursor.startsWith('swarm-repost:')) {
|
||||
const repostRow = await db.query.userSwarmReposts.findFirst({
|
||||
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
|
||||
});
|
||||
return repostRow?.repostedAt ?? null;
|
||||
}
|
||||
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
return cursorPost?.createdAt ?? null;
|
||||
}
|
||||
|
||||
function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
|
||||
const collected: FeedPostWithChildren[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const visit = (post: FeedPostWithChildren | null | undefined) => {
|
||||
if (!post || seen.has(post.id)) return;
|
||||
seen.add(post.id);
|
||||
collected.push(post);
|
||||
visit(post.repostOf);
|
||||
visit(post.replyTo);
|
||||
};
|
||||
|
||||
posts.forEach(visit);
|
||||
return collected;
|
||||
}
|
||||
|
||||
function applyInteractionFlags(
|
||||
posts: FeedPostWithChildren[],
|
||||
likedIds: Set<string>,
|
||||
repostedIds: Set<string>
|
||||
): FeedPostWithChildren[] {
|
||||
return posts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedIds.has(post.id),
|
||||
isReposted: repostedIds.has(post.id),
|
||||
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
|
||||
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
|
||||
}));
|
||||
}
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const feedPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const createPostSchema = z.object({
|
||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
|
||||
@@ -38,21 +172,40 @@ const createPostSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image: z.string().url().optional().nullable(),
|
||||
type: z.enum(['card', 'image', 'gallery', 'video']).optional().nullable(),
|
||||
videoUrl: z.string().url().optional().nullable(),
|
||||
media: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
width: z.number().optional().nullable(),
|
||||
height: z.number().optional().nullable(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
})).optional().nullable(),
|
||||
}).optional().nullable(),
|
||||
});
|
||||
|
||||
function isSignedActionPayload(payload: unknown): payload is SignedAction {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const value = payload as Record<string, unknown>;
|
||||
return typeof value.action === 'string'
|
||||
&& typeof value.did === 'string'
|
||||
&& typeof value.handle === 'string'
|
||||
&& typeof value.ts === 'number'
|
||||
&& typeof value.nonce === 'string'
|
||||
&& typeof value.sig === 'string'
|
||||
&& typeof value.data === 'object'
|
||||
&& value.data !== null;
|
||||
}
|
||||
|
||||
// Create a new post
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Strictly verify the signature and get the user
|
||||
// This replaces requireAuth() - the signature proves identity AND intent
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract post data from the signed action
|
||||
const data = createPostSchema.parse(signedAction.data);
|
||||
const requestBody = await request.json();
|
||||
const user = isSignedActionPayload(requestBody)
|
||||
? await requireSignedAction(requestBody)
|
||||
: await requireAuth();
|
||||
const data = createPostSchema.parse(
|
||||
isSignedActionPayload(requestBody) ? requestBody.data : requestBody
|
||||
);
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
@@ -84,6 +237,9 @@ export async function POST(request: Request) {
|
||||
linkPreviewTitle: data.linkPreview?.title,
|
||||
linkPreviewDescription: data.linkPreview?.description,
|
||||
linkPreviewImage: data.linkPreview?.image,
|
||||
linkPreviewType: data.linkPreview?.type,
|
||||
linkPreviewVideoUrl: data.linkPreview?.videoUrl,
|
||||
linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media),
|
||||
}).returning();
|
||||
|
||||
let unattachedMedia: typeof media.$inferSelect[] = [];
|
||||
@@ -181,12 +337,56 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.replyToId) {
|
||||
try {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.replyToId),
|
||||
with: {
|
||||
author: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (parentPost && parentPost.userId !== user.id) {
|
||||
const parentAuthor = parentPost.author as typeof users.$inferSelect | undefined;
|
||||
|
||||
await db.insert(notifications).values({
|
||||
userId: parentPost.userId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: parentPost.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor) : {}),
|
||||
type: 'reply',
|
||||
});
|
||||
|
||||
if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: parentAuthor.botOwnerId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: parentPost.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(parentAuthor),
|
||||
type: 'reply',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Posts] Error creating reply notifications:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, replyToId: data.replyToId, userId: user.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local mentions (create notifications for users on this node)
|
||||
(async () => {
|
||||
try {
|
||||
const { extractMentions } = await import('@/lib/swarm/interactions');
|
||||
const { notifications } = await import('@/db');
|
||||
|
||||
const mentions = extractMentions(data.content);
|
||||
|
||||
for (const mention of mentions) {
|
||||
@@ -209,6 +409,7 @@ export async function POST(request: Request) {
|
||||
actorNodeDomain: null, // Local user
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||
type: 'mention',
|
||||
});
|
||||
|
||||
@@ -223,6 +424,7 @@ export async function POST(request: Request) {
|
||||
actorNodeDomain: null,
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(mentionedUser),
|
||||
type: 'mention',
|
||||
});
|
||||
}
|
||||
@@ -374,6 +576,9 @@ const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]
|
||||
linkPreviewTitle: rp.linkPreviewTitle,
|
||||
linkPreviewDescription: rp.linkPreviewDescription,
|
||||
linkPreviewImage: rp.linkPreviewImage,
|
||||
linkPreviewType: rp.linkPreviewType,
|
||||
linkPreviewVideoUrl: rp.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(rp.linkPreviewMediaJson) || null,
|
||||
author: {
|
||||
id: rp.authorActorUrl,
|
||||
handle: rp.authorHandle,
|
||||
@@ -437,14 +642,7 @@ export async function GET(request: Request) {
|
||||
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -452,14 +650,7 @@ export async function GET(request: Request) {
|
||||
// Public timeline - all local posts + all cached remote posts
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: limit * 2,
|
||||
});
|
||||
@@ -492,14 +683,7 @@ export async function GET(request: Request) {
|
||||
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -519,14 +703,7 @@ export async function GET(request: Request) {
|
||||
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -554,7 +731,7 @@ export async function GET(request: Request) {
|
||||
includeNsfw,
|
||||
});
|
||||
|
||||
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||
const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({
|
||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||
originalPostId: sp.id, // Keep the original ID for replies
|
||||
content: sp.content,
|
||||
@@ -564,6 +741,8 @@ export async function GET(request: Request) {
|
||||
repliesCount: sp.replyCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null,
|
||||
repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null,
|
||||
author: {
|
||||
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
|
||||
handle: sp.author.handle,
|
||||
@@ -583,8 +762,13 @@ export async function GET(request: Request) {
|
||||
linkPreviewTitle: sp.linkPreviewTitle || null,
|
||||
linkPreviewDescription: sp.linkPreviewDescription || null,
|
||||
linkPreviewImage: sp.linkPreviewImage || null,
|
||||
linkPreviewType: sp.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null,
|
||||
linkPreviewMedia: sp.linkPreviewMedia || null,
|
||||
replyTo: null,
|
||||
}));
|
||||
});
|
||||
|
||||
const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost);
|
||||
|
||||
let mutedIds = new Set<string>();
|
||||
let blockedIds = new Set<string>();
|
||||
@@ -674,31 +858,48 @@ export async function GET(request: Request) {
|
||||
|
||||
// Build where condition with cursor support
|
||||
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
|
||||
const cursorDate = await getMixedFeedCursorDate(cursor);
|
||||
|
||||
if (cursor) {
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
if (cursorPost) {
|
||||
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt));
|
||||
}
|
||||
if (cursorDate) {
|
||||
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate));
|
||||
}
|
||||
|
||||
// Get local posts from people the user follows + their own posts
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
|
||||
});
|
||||
|
||||
const swarmRepostWhere = cursorDate
|
||||
? and(
|
||||
inArray(userSwarmReposts.userId, allowedUserIds),
|
||||
lt(userSwarmReposts.repostedAt, cursorDate)
|
||||
)
|
||||
: inArray(userSwarmReposts.userId, allowedUserIds);
|
||||
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
|
||||
where: swarmRepostWhere,
|
||||
orderBy: [desc(userSwarmReposts.repostedAt)],
|
||||
limit: cursor ? limit : limit * 2,
|
||||
});
|
||||
|
||||
const swarmRepostAuthors = swarmRepostRows.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: inArray(users.id, Array.from(new Set(swarmRepostRows.map((row) => row.userId)))),
|
||||
})
|
||||
: [];
|
||||
const swarmRepostAuthorMap = new Map(swarmRepostAuthors.map((author) => [author.id, author]));
|
||||
const localRepostEvents = swarmRepostRows
|
||||
.map((row) => {
|
||||
const author = swarmRepostAuthorMap.get(row.userId);
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
return mapUserSwarmRepostToFeedPost(row, author);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Get handles of remote users we follow
|
||||
const followedRemoteUsers = await db.query.remoteFollows.findMany({
|
||||
where: eq(remoteFollows.followerId, user.id),
|
||||
@@ -708,6 +909,7 @@ export async function GET(request: Request) {
|
||||
let liveRemotePosts: any[] = [];
|
||||
if (followedRemoteUsers.length > 0) {
|
||||
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
|
||||
const { mapRemoteProfilePost } = await import('@/lib/swarm/remote-profile-posts');
|
||||
|
||||
// Wrap each fetch with a timeout to prevent slow nodes from blocking
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||
@@ -737,34 +939,15 @@ export async function GET(request: Request) {
|
||||
|
||||
return profileData.posts
|
||||
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
||||
.map(post => ({
|
||||
id: `swarm:${domain}:${post.id}`,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
isRemote: true,
|
||||
isNsfw: post.isNsfw,
|
||||
linkPreviewUrl: post.linkPreviewUrl,
|
||||
linkPreviewTitle: post.linkPreviewTitle,
|
||||
linkPreviewDescription: post.linkPreviewDescription,
|
||||
linkPreviewImage: post.linkPreviewImage,
|
||||
author: {
|
||||
id: `swarm:${domain}:${handle}`,
|
||||
handle: follow.targetHandle,
|
||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||
isRemote: true,
|
||||
isBot: profileData.profile?.isBot,
|
||||
},
|
||||
media: post.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
replyTo: null,
|
||||
}));
|
||||
.map((post: any) => mapRemoteProfilePost({
|
||||
...post,
|
||||
author: post.author || {
|
||||
handle,
|
||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||
isBot: profileData.profile?.isBot,
|
||||
},
|
||||
}, domain));
|
||||
} catch (error) {
|
||||
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
|
||||
return [];
|
||||
@@ -776,7 +959,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Merge and sort by date
|
||||
const allPosts = [...localPosts, ...liveRemotePosts]
|
||||
const allPosts = [...localPosts, ...localRepostEvents, ...liveRemotePosts]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
@@ -785,14 +968,7 @@ export async function GET(request: Request) {
|
||||
// Not authenticated, return public timeline
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true, media: true },
|
||||
},
|
||||
},
|
||||
with: feedPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
@@ -807,12 +983,13 @@ export async function GET(request: Request) {
|
||||
if (session?.user && feedPosts && feedPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]);
|
||||
|
||||
// Separate local and swarm posts
|
||||
const localPostIds: string[] = [];
|
||||
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
|
||||
|
||||
for (const p of feedPosts as Array<{ id: string }>) {
|
||||
for (const p of allFeedPosts) {
|
||||
if (p.id.startsWith('swarm:')) {
|
||||
const parts = p.id.split(':');
|
||||
if (parts.length >= 3) {
|
||||
@@ -852,6 +1029,8 @@ export async function GET(request: Request) {
|
||||
|
||||
// Check swarm likes in real-time (query origin nodes)
|
||||
if (swarmPosts.length > 0) {
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
|
||||
const checkPromises = swarmPosts.map(async (sp) => {
|
||||
try {
|
||||
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
|
||||
@@ -874,13 +1053,23 @@ export async function GET(request: Request) {
|
||||
});
|
||||
|
||||
await Promise.all(checkPromises);
|
||||
|
||||
const swarmRepostedIds = await getViewerSwarmRepostedPostIds(
|
||||
swarmPosts.map((sp) => ({
|
||||
id: sp.id,
|
||||
nodeDomain: sp.domain,
|
||||
originalPostId: sp.originalId,
|
||||
})),
|
||||
viewer.id
|
||||
);
|
||||
swarmRepostedIds.forEach((id) => repostedPostIds.add(id));
|
||||
}
|
||||
|
||||
feedPosts = feedPosts.map((p: { id: string }) => ({
|
||||
...p,
|
||||
isLiked: likedPostIds.has(p.id),
|
||||
isReposted: repostedPostIds.has(p.id),
|
||||
})) as any;
|
||||
feedPosts = applyInteractionFlags(
|
||||
feedPosts as FeedPostWithChildren[],
|
||||
likedPostIds,
|
||||
repostedPostIds
|
||||
) as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error populating interaction flags:', error);
|
||||
|
||||
@@ -8,6 +8,51 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
|
||||
type SwarmFeedPost = {
|
||||
id: string;
|
||||
nodeDomain: string;
|
||||
repostOf?: SwarmFeedPost | null;
|
||||
replyTo?: SwarmFeedPost | null;
|
||||
isLiked?: boolean;
|
||||
isReposted?: boolean;
|
||||
};
|
||||
|
||||
function collectNestedSwarmPosts(posts: SwarmFeedPost[]): SwarmFeedPost[] {
|
||||
const collected: SwarmFeedPost[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const visit = (post: SwarmFeedPost | null | undefined) => {
|
||||
if (!post) return;
|
||||
const key = `${post.nodeDomain}:${post.id}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
collected.push(post);
|
||||
visit(post.repostOf);
|
||||
visit(post.replyTo);
|
||||
};
|
||||
|
||||
posts.forEach(visit);
|
||||
return collected;
|
||||
}
|
||||
|
||||
function applyInteractionFlags(
|
||||
posts: SwarmFeedPost[],
|
||||
likedIds: Set<string>,
|
||||
repostedIds: Set<string>
|
||||
): SwarmFeedPost[] {
|
||||
return posts.map((post) => {
|
||||
const normalizedId = `swarm:${post.nodeDomain}:${post.id}`;
|
||||
return {
|
||||
...post,
|
||||
isLiked: likedIds.has(normalizedId),
|
||||
isReposted: repostedIds.has(normalizedId),
|
||||
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
|
||||
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/posts/swarm
|
||||
@@ -35,9 +80,10 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getSession().catch(() => null);
|
||||
const viewer = session?.user;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
|
||||
const likedPostIds = viewer
|
||||
? await getViewerSwarmLikedPostIds(
|
||||
timeline.posts.map(post => ({
|
||||
allTimelinePosts.map(post => ({
|
||||
id: `swarm:${post.nodeDomain}:${post.id}`,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.id,
|
||||
@@ -46,12 +92,19 @@ export async function GET(request: NextRequest) {
|
||||
nodeDomain
|
||||
)
|
||||
: new Set<string>();
|
||||
const repostedPostIds = viewer
|
||||
? await getViewerSwarmRepostedPostIds(
|
||||
allTimelinePosts.map(post => ({
|
||||
id: `swarm:${post.nodeDomain}:${post.id}`,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.id,
|
||||
})),
|
||||
viewer.id
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
return NextResponse.json({
|
||||
posts: timeline.posts.map(post => ({
|
||||
...post,
|
||||
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
||||
})),
|
||||
posts: applyInteractionFlags(timeline.posts as SwarmFeedPost[], likedPostIds, repostedPostIds),
|
||||
sources: timeline.sources,
|
||||
cached: false,
|
||||
fetchedAt: timeline.fetchedAt,
|
||||
|
||||
@@ -4,6 +4,26 @@ import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const searchPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type SearchUser = {
|
||||
id: string;
|
||||
handle: string;
|
||||
@@ -176,11 +196,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
const postResults = await db.query.posts.findMany({
|
||||
where: and(...postConditions),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
},
|
||||
with: searchPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for conversation ID parameter
|
||||
@@ -73,10 +74,14 @@ export async function DELETE(
|
||||
|
||||
if (isRemote) {
|
||||
// Extract domain from handle (format: handle@domain)
|
||||
const domain = participant2Handle.split('@')[1];
|
||||
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
|
||||
const handle = participant2Handle.split('@')[0];
|
||||
|
||||
try {
|
||||
if (await isNodeBlocked(domain)) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { db, users, chatConversations } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||
|
||||
const deletionSchema = z.object({
|
||||
senderHandle: z.string(),
|
||||
@@ -30,6 +31,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const body = await request.json();
|
||||
const data = deletionSchema.parse(body);
|
||||
const senderNodeDomain = normalizeNodeDomain(data.senderNodeDomain);
|
||||
|
||||
if (await isNodeBlocked(senderNodeDomain)) {
|
||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
||||
}
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
|
||||
payload,
|
||||
signature,
|
||||
data.senderHandle,
|
||||
data.senderNodeDomain
|
||||
senderNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
@@ -55,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Find the conversation with the sender
|
||||
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
|
||||
const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipient.id),
|
||||
|
||||
@@ -29,6 +29,14 @@ const swarmPostSchema = z.object({
|
||||
linkPreviewTitle: z.string().optional(),
|
||||
linkPreviewDescription: z.string().optional(),
|
||||
linkPreviewImage: z.string().optional(),
|
||||
linkPreviewType: z.enum(['card', 'image', 'gallery', 'video']).optional(),
|
||||
linkPreviewVideoUrl: z.string().optional(),
|
||||
linkPreviewMedia: z.array(z.object({
|
||||
url: z.string(),
|
||||
width: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
mimeType: z.string().nullable().optional(),
|
||||
})).optional(),
|
||||
}),
|
||||
author: z.object({
|
||||
handle: z.string(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: localHandleSchema,
|
||||
@@ -107,6 +108,7 @@ export async function POST(request: NextRequest) {
|
||||
actorDisplayName: data.follow.followerDisplayName,
|
||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||
actorNodeDomain: data.follow.followerNodeDomain,
|
||||
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
|
||||
type: 'follow',
|
||||
});
|
||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
||||
@@ -125,6 +127,7 @@ export async function POST(request: NextRequest) {
|
||||
actorDisplayName: data.follow.followerDisplayName,
|
||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||
actorNodeDomain: data.follow.followerNodeDomain,
|
||||
...buildNotificationTarget(targetUser),
|
||||
type: 'follow',
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
const swarmLikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -88,6 +89,8 @@ export async function POST(request: NextRequest) {
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
actorNodeDomain: data.like.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
|
||||
type: 'like',
|
||||
});
|
||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
||||
@@ -108,7 +112,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||
if (author?.isBot && author.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
@@ -119,6 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
actorNodeDomain: data.like.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(author as any),
|
||||
type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
const swarmMentionSchema = z.object({
|
||||
mentionedHandle: localHandleSchema,
|
||||
@@ -73,6 +74,7 @@ export async function POST(request: NextRequest) {
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||
type: 'mention',
|
||||
});
|
||||
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
||||
@@ -90,6 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
...buildNotificationTarget(mentionedUser),
|
||||
type: 'mention',
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db, posts, users, notifications, remoteReposts } from '@/db';
|
||||
import { eq, sql, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
const swarmRepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -64,11 +65,34 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingRepost = await db.query.remoteReposts.findFirst({
|
||||
where: and(
|
||||
eq(remoteReposts.postId, data.postId),
|
||||
eq(remoteReposts.actorHandle, data.repost.actorHandle),
|
||||
eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRepost) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Repost already recorded',
|
||||
});
|
||||
}
|
||||
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
await db.insert(remoteReposts).values({
|
||||
postId: data.postId,
|
||||
actorHandle: data.repost.actorHandle,
|
||||
actorNodeDomain: data.repost.actorNodeDomain,
|
||||
});
|
||||
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
@@ -79,6 +103,7 @@ export async function POST(request: NextRequest) {
|
||||
actorNodeDomain: data.repost.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
|
||||
type: 'repost',
|
||||
});
|
||||
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
|
||||
@@ -87,7 +112,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||
if (author?.isBot && author.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
@@ -98,6 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
actorNodeDomain: data.repost.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(author as any),
|
||||
type: 'repost',
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db, posts, remoteReposts } from '@/db';
|
||||
import { eq, sql, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
@@ -60,11 +60,28 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingRepost = await db.query.remoteReposts.findFirst({
|
||||
where: and(
|
||||
eq(remoteReposts.postId, data.postId),
|
||||
eq(remoteReposts.actorHandle, data.unrepost.actorHandle),
|
||||
eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingRepost) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Repost already removed',
|
||||
});
|
||||
}
|
||||
|
||||
// Decrement repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id));
|
||||
|
||||
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { db, posts, userSwarmReposts, users } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
});
|
||||
|
||||
if (!post || post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
const remoteRepost = await db.query.userSwarmReposts.findFirst({
|
||||
where: eq(userSwarmReposts.id, postId),
|
||||
});
|
||||
|
||||
if (!remoteRepost) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const repostAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, remoteRepost.userId),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
post: {
|
||||
id: remoteRepost.id,
|
||||
apId: null,
|
||||
content: '',
|
||||
createdAt: remoteRepost.repostedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: repostAuthor ? {
|
||||
handle: repostAuthor.handle,
|
||||
displayName: repostAuthor.displayName,
|
||||
avatarUrl: repostAuthor.avatarUrl,
|
||||
} : null,
|
||||
media: [],
|
||||
repostOfId: remoteRepost.originalPostId,
|
||||
repostOf: {
|
||||
id: remoteRepost.originalPostId,
|
||||
originalPostId: remoteRepost.originalPostId,
|
||||
content: remoteRepost.content,
|
||||
createdAt: remoteRepost.postCreatedAt.toISOString(),
|
||||
likesCount: remoteRepost.likesCount,
|
||||
repostsCount: remoteRepost.repostsCount,
|
||||
repliesCount: remoteRepost.repliesCount,
|
||||
nodeDomain: remoteRepost.nodeDomain,
|
||||
author: {
|
||||
handle: remoteRepost.authorHandle.includes('@')
|
||||
? remoteRepost.authorHandle
|
||||
: `${remoteRepost.authorHandle}@${remoteRepost.nodeDomain}`,
|
||||
displayName: remoteRepost.authorDisplayName,
|
||||
avatarUrl: remoteRepost.authorAvatarUrl,
|
||||
},
|
||||
media: remoteRepost.mediaJson ? JSON.parse(remoteRepost.mediaJson) : [],
|
||||
linkPreviewUrl: remoteRepost.linkPreviewUrl,
|
||||
linkPreviewTitle: remoteRepost.linkPreviewTitle,
|
||||
linkPreviewDescription: remoteRepost.linkPreviewDescription,
|
||||
linkPreviewImage: remoteRepost.linkPreviewImage,
|
||||
linkPreviewType: remoteRepost.linkPreviewType,
|
||||
linkPreviewVideoUrl: remoteRepost.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(remoteRepost.linkPreviewMediaJson) || [],
|
||||
},
|
||||
},
|
||||
replies: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Get replies
|
||||
@@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
linkPreviewTitle: post.linkPreviewTitle,
|
||||
linkPreviewDescription: post.linkPreviewDescription,
|
||||
linkPreviewImage: post.linkPreviewImage,
|
||||
linkPreviewType: post.linkPreviewType,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [],
|
||||
},
|
||||
replies: replies.map(r => {
|
||||
const replyAuthor = r.author as any;
|
||||
@@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
url: m.url,
|
||||
altText: m.altText,
|
||||
})) || [],
|
||||
linkPreviewUrl: r.linkPreviewUrl,
|
||||
linkPreviewTitle: r.linkPreviewTitle,
|
||||
linkPreviewDescription: r.linkPreviewDescription,
|
||||
linkPreviewImage: r.linkPreviewImage,
|
||||
linkPreviewType: r.linkPreviewType,
|
||||
linkPreviewVideoUrl: r.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(r.linkPreviewMediaJson) || [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { eq, desc, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
// Schema for incoming swarm reply
|
||||
const swarmReplySchema = z.object({
|
||||
@@ -145,6 +146,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
await syncParentReplyCount(data.postId);
|
||||
|
||||
const parentAuthor = parentPost.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
|
||||
|
||||
if (parentPost.userId !== remoteUser.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: parentPost.userId,
|
||||
@@ -154,8 +157,23 @@ export async function POST(request: NextRequest) {
|
||||
actorNodeDomain: sourceDomain,
|
||||
postId: data.postId,
|
||||
postContent: data.reply.content.slice(0, 200),
|
||||
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}),
|
||||
type: 'reply',
|
||||
});
|
||||
|
||||
if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: parentAuthor.botOwnerId,
|
||||
actorHandle: data.reply.author.handle,
|
||||
actorDisplayName: data.reply.author.displayName || data.reply.author.handle,
|
||||
actorAvatarUrl: data.reply.author.avatarUrl || null,
|
||||
actorNodeDomain: sourceDomain,
|
||||
postId: data.postId,
|
||||
postContent: data.reply.content.slice(0, 200),
|
||||
...buildNotificationTarget(parentAuthor as any),
|
||||
type: 'reply',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Reply received' });
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, isNull, lt, sql, inArray } from 'drizzle-orm';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
export interface SwarmPost {
|
||||
id: string;
|
||||
@@ -15,6 +16,8 @@ export interface SwarmPost {
|
||||
isReply?: boolean;
|
||||
replyToId?: string | null;
|
||||
swarmReplyToId?: string | null;
|
||||
repostOfId?: string | null;
|
||||
repostOf?: SwarmPost | null;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -34,6 +37,74 @@ export interface SwarmPost {
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
|
||||
linkPreviewVideoUrl?: string;
|
||||
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
|
||||
}
|
||||
|
||||
interface TimelinePostRow {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
replyToId: string | null;
|
||||
swarmReplyToId: string | null;
|
||||
repostOfId: string | null;
|
||||
isNsfw: boolean;
|
||||
likesCount: number;
|
||||
repostsCount: number;
|
||||
repliesCount: number;
|
||||
linkPreviewUrl: string | null;
|
||||
linkPreviewTitle: string | null;
|
||||
linkPreviewDescription: string | null;
|
||||
linkPreviewImage: string | null;
|
||||
linkPreviewType: string | null;
|
||||
linkPreviewVideoUrl: string | null;
|
||||
linkPreviewMediaJson: string | null;
|
||||
authorHandle: string;
|
||||
authorDisplayName: string | null;
|
||||
authorAvatarUrl: string | null;
|
||||
authorIsNsfw: boolean;
|
||||
authorIsBot: boolean | null;
|
||||
}
|
||||
|
||||
function buildSwarmPost(
|
||||
post: TimelinePostRow,
|
||||
mediaByPostId: Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>,
|
||||
repostById: Map<string, SwarmPost>,
|
||||
nodeDomain: string,
|
||||
nodeIsNsfw: boolean
|
||||
): SwarmPost {
|
||||
return {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isReply: Boolean(post.replyToId || post.swarmReplyToId),
|
||||
replyToId: post.replyToId,
|
||||
swarmReplyToId: post.swarmReplyToId,
|
||||
repostOfId: post.repostOfId,
|
||||
repostOf: post.repostOfId ? repostById.get(post.repostOfId) || null : null,
|
||||
author: {
|
||||
handle: post.authorHandle,
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
avatarUrl: post.authorAvatarUrl || undefined,
|
||||
isNsfw: post.authorIsNsfw,
|
||||
isBot: post.authorIsBot || undefined,
|
||||
},
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
isNsfw: post.isNsfw || post.authorIsNsfw,
|
||||
likeCount: post.likesCount,
|
||||
repostCount: post.repostsCount,
|
||||
replyCount: post.repliesCount,
|
||||
media: mediaByPostId.get(post.id),
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
linkPreviewType: (post.linkPreviewType as SwarmPost['linkPreviewType']) || undefined,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +160,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: posts.createdAt,
|
||||
replyToId: posts.replyToId,
|
||||
swarmReplyToId: posts.swarmReplyToId,
|
||||
repostOfId: posts.repostOfId,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
@@ -97,6 +169,9 @@ export async function GET(request: NextRequest) {
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
linkPreviewType: posts.linkPreviewType,
|
||||
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
|
||||
linkPreviewMediaJson: posts.linkPreviewMediaJson,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
@@ -112,47 +187,84 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmPost[] = [];
|
||||
const repostIds = Array.from(new Set(
|
||||
recentPosts
|
||||
.map(post => post.repostOfId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
));
|
||||
|
||||
for (const post of recentPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
const repostTargets = repostIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
replyToId: posts.replyToId,
|
||||
swarmReplyToId: posts.swarmReplyToId,
|
||||
repostOfId: posts.repostOfId,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
linkPreviewType: posts.linkPreviewType,
|
||||
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
|
||||
linkPreviewMediaJson: posts.linkPreviewMediaJson,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
authorIsNsfw: users.isNsfw,
|
||||
authorIsBot: users.isBot,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(and(
|
||||
inArray(posts.id, repostIds),
|
||||
eq(posts.isRemoved, false),
|
||||
))
|
||||
: [];
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isReply: Boolean(post.replyToId || post.swarmReplyToId),
|
||||
replyToId: post.replyToId,
|
||||
swarmReplyToId: post.swarmReplyToId,
|
||||
author: {
|
||||
handle: post.authorHandle,
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
avatarUrl: post.authorAvatarUrl || undefined,
|
||||
isNsfw: post.authorIsNsfw,
|
||||
isBot: post.authorIsBot,
|
||||
},
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
|
||||
likeCount: post.likesCount,
|
||||
repostCount: post.repostsCount,
|
||||
replyCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
const mediaPostIds = Array.from(new Set([
|
||||
...recentPosts.map(post => post.id),
|
||||
...repostTargets.map(post => post.id),
|
||||
]));
|
||||
|
||||
const mediaRows = mediaPostIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
postId: media.postId,
|
||||
url: media.url,
|
||||
mimeType: media.mimeType,
|
||||
altText: media.altText,
|
||||
})
|
||||
.from(media)
|
||||
.where(inArray(media.postId, mediaPostIds))
|
||||
: [];
|
||||
|
||||
const mediaByPostId = new Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>();
|
||||
for (const item of mediaRows) {
|
||||
if (!item.postId) continue;
|
||||
const bucket = mediaByPostId.get(item.postId) || [];
|
||||
bucket.push({
|
||||
url: item.url,
|
||||
mimeType: item.mimeType || undefined,
|
||||
altText: item.altText || undefined,
|
||||
});
|
||||
mediaByPostId.set(item.postId, bucket);
|
||||
}
|
||||
|
||||
const repostById = new Map<string, SwarmPost>();
|
||||
for (const post of repostTargets) {
|
||||
repostById.set(post.id, buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw));
|
||||
}
|
||||
|
||||
const swarmPosts = recentPosts.map(post =>
|
||||
buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { db, posts, users, userSwarmReposts } from '@/db';
|
||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
export interface SwarmUserProfile {
|
||||
handle: string;
|
||||
@@ -28,21 +29,144 @@ export interface SwarmUserProfile {
|
||||
|
||||
export interface SwarmUserPost {
|
||||
id: string;
|
||||
originalPostId?: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isNsfw: boolean;
|
||||
likesCount: number;
|
||||
repostsCount: number;
|
||||
repliesCount: number;
|
||||
nodeDomain?: string;
|
||||
author?: {
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
isBot?: boolean;
|
||||
nodeDomain?: string;
|
||||
};
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
|
||||
linkPreviewVideoUrl?: string;
|
||||
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
|
||||
repostOfId?: string;
|
||||
repostOf?: SwarmUserPost | null;
|
||||
}
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const profilePostRelations = {
|
||||
author: true,
|
||||
media: true,
|
||||
repostOf: {
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseMediaJson(mediaJson: string | null) {
|
||||
if (!mediaJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(mediaJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
|
||||
return {
|
||||
id: post.id,
|
||||
originalPostId: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isNsfw: post.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
nodeDomain,
|
||||
author: post.author ? {
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName || post.author.handle,
|
||||
avatarUrl: post.author.avatarUrl || undefined,
|
||||
isBot: post.author.isBot || undefined,
|
||||
nodeDomain,
|
||||
} : undefined,
|
||||
media: (post.media || []).map((item: any) => ({
|
||||
url: item.url,
|
||||
mimeType: item.mimeType || undefined,
|
||||
altText: item.altText || undefined,
|
||||
})),
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
linkPreviewType: post.linkPreviewType || undefined,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
|
||||
repostOfId: post.repostOfId || undefined,
|
||||
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserSwarmRepostToSwarmPost(
|
||||
row: typeof userSwarmReposts.$inferSelect,
|
||||
author: typeof users.$inferSelect,
|
||||
nodeDomain: string
|
||||
): SwarmUserPost {
|
||||
return {
|
||||
id: row.id,
|
||||
originalPostId: row.id,
|
||||
content: '',
|
||||
createdAt: row.repostedAt.toISOString(),
|
||||
isNsfw: false,
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
nodeDomain,
|
||||
author: {
|
||||
handle: author.handle,
|
||||
displayName: author.displayName || author.handle,
|
||||
avatarUrl: author.avatarUrl || undefined,
|
||||
isBot: author.isBot || undefined,
|
||||
nodeDomain,
|
||||
},
|
||||
repostOfId: row.originalPostId,
|
||||
repostOf: {
|
||||
id: row.originalPostId,
|
||||
originalPostId: row.originalPostId,
|
||||
content: row.content,
|
||||
createdAt: row.postCreatedAt.toISOString(),
|
||||
isNsfw: false,
|
||||
likesCount: row.likesCount,
|
||||
repostsCount: row.repostsCount,
|
||||
repliesCount: row.repliesCount,
|
||||
nodeDomain: row.nodeDomain,
|
||||
author: {
|
||||
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
|
||||
displayName: row.authorDisplayName || row.authorHandle,
|
||||
avatarUrl: row.authorAvatarUrl || undefined,
|
||||
nodeDomain: row.nodeDomain,
|
||||
},
|
||||
media: parseMediaJson(row.mediaJson),
|
||||
linkPreviewUrl: row.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: row.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: row.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: row.linkPreviewImage || undefined,
|
||||
linkPreviewType: (row.linkPreviewType as SwarmUserPost['linkPreviewType']) || undefined,
|
||||
linkPreviewVideoUrl: row.linkPreviewVideoUrl || undefined,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/swarm/users/[handle]
|
||||
*
|
||||
@@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
did: user.did || undefined,
|
||||
};
|
||||
|
||||
// Get user's recent posts
|
||||
const userPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
})
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId)
|
||||
),
|
||||
with: profilePostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: limit * 2,
|
||||
});
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmUserPost[] = [];
|
||||
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
|
||||
where: eq(userSwarmReposts.userId, user.id),
|
||||
orderBy: [desc(userSwarmReposts.repostedAt)],
|
||||
limit: limit * 2,
|
||||
});
|
||||
|
||||
for (const post of userPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isNsfw: post.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
const swarmPosts: SwarmUserPost[] = [
|
||||
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
|
||||
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
|
||||
]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
return NextResponse.json({
|
||||
profile,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
@@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
@@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
...buildNotificationTarget(targetUser),
|
||||
type: 'follow',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const likedPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const parseMediaJson = (mediaJson: string | null) => {
|
||||
if (!mediaJson) {
|
||||
return [];
|
||||
@@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
const fetchRemoteLikesRoute = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/likes?limit=${limit}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
const viewer = session?.user;
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
const repostedIds = viewer
|
||||
? await getViewerSwarmRepostedPostIds(
|
||||
mappedPosts.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.originalPostId || post.id.split(':').pop(),
|
||||
})),
|
||||
viewer.id
|
||||
)
|
||||
: new Set<string>();
|
||||
return NextResponse.json({
|
||||
posts: mappedPosts.map((post: any) => ({
|
||||
...post,
|
||||
isReposted: repostedIds.has(post.id),
|
||||
})),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteLikesRoute();
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteLikesRoute();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
@@ -42,11 +144,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
where: eq(likes.userId, user.id),
|
||||
with: {
|
||||
post: {
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
},
|
||||
with: likedPostRelations,
|
||||
},
|
||||
},
|
||||
orderBy: [desc(likes.createdAt)],
|
||||
@@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
linkPreviewTitle: like.linkPreviewTitle,
|
||||
linkPreviewDescription: like.linkPreviewDescription,
|
||||
linkPreviewImage: like.linkPreviewImage,
|
||||
linkPreviewType: like.linkPreviewType,
|
||||
linkPreviewVideoUrl: like.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: like.nodeDomain,
|
||||
likedAt: like.likedAt.toISOString(),
|
||||
@@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
.filter((post: any) => !post.isSwarm)
|
||||
.map((post: any) => post.id)
|
||||
.filter(Boolean);
|
||||
const swarmTargets = likedPosts
|
||||
.filter((post: any) => post.isSwarm)
|
||||
.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
}))
|
||||
.filter((post: any) => post.nodeDomain && post.originalPostId);
|
||||
const swarmRepostedIds = swarmTargets.length > 0
|
||||
? await getViewerSwarmRepostedPostIds(swarmTargets as any, viewer.id)
|
||||
: new Set<string>();
|
||||
|
||||
if (localPostIds.length > 0) {
|
||||
const viewerLikes = await db.query.likes.findMany({
|
||||
@@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
likedPosts = likedPosts.map(p => ({
|
||||
...p!,
|
||||
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
|
||||
isReposted: repostedPostIds.has(p!.id),
|
||||
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : repostedPostIds.has(p!.id),
|
||||
})) as any;
|
||||
} else {
|
||||
likedPosts = likedPosts.map(p => ({
|
||||
...p!,
|
||||
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
|
||||
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted,
|
||||
})) as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,169 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, likes } from '@/db';
|
||||
import { db, posts, users, likes, userSwarmReposts } from '@/db';
|
||||
import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const userPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const parseRemoteHandle = (handle: string) => {
|
||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
return null;
|
||||
type FeedPostWithChildren = {
|
||||
id: string;
|
||||
createdAt?: string | Date;
|
||||
repostOf?: FeedPostWithChildren | null;
|
||||
replyTo?: FeedPostWithChildren | null;
|
||||
isLiked?: boolean;
|
||||
isReposted?: boolean;
|
||||
nodeDomain?: string | null;
|
||||
originalPostId?: string | null;
|
||||
};
|
||||
|
||||
function parseMediaJson(mediaJson: string | null) {
|
||||
if (!mediaJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(mediaJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserSwarmRepostToFeedPost(
|
||||
row: typeof userSwarmReposts.$inferSelect,
|
||||
author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'>
|
||||
): FeedPostWithChildren {
|
||||
const remoteAuthorHandle = row.authorHandle.includes('@')
|
||||
? row.authorHandle
|
||||
: `${row.authorHandle}@${row.nodeDomain}`;
|
||||
const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
|
||||
|
||||
return {
|
||||
id: `swarm-repost:${row.id}`,
|
||||
content: '',
|
||||
createdAt: row.repostedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: {
|
||||
id: author.id,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
avatarUrl: author.avatarUrl,
|
||||
isBot: author.isBot,
|
||||
},
|
||||
repostOfId: remoteOriginalId,
|
||||
repostOf: {
|
||||
id: remoteOriginalId,
|
||||
originalPostId: row.originalPostId,
|
||||
content: row.content,
|
||||
createdAt: row.postCreatedAt.toISOString(),
|
||||
likesCount: row.likesCount,
|
||||
repostsCount: row.repostsCount,
|
||||
repliesCount: row.repliesCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
author: {
|
||||
id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
|
||||
handle: remoteAuthorHandle,
|
||||
displayName: row.authorDisplayName || row.authorHandle,
|
||||
avatarUrl: row.authorAvatarUrl,
|
||||
isRemote: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
},
|
||||
media: parseMediaJson(row.mediaJson),
|
||||
linkPreviewUrl: row.linkPreviewUrl,
|
||||
linkPreviewTitle: row.linkPreviewTitle,
|
||||
linkPreviewDescription: row.linkPreviewDescription,
|
||||
linkPreviewImage: row.linkPreviewImage,
|
||||
linkPreviewType: row.linkPreviewType,
|
||||
linkPreviewVideoUrl: row.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
|
||||
const collected: FeedPostWithChildren[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const visit = (post: FeedPostWithChildren | null | undefined) => {
|
||||
if (!post || seen.has(post.id)) return;
|
||||
seen.add(post.id);
|
||||
collected.push(post);
|
||||
visit(post.repostOf);
|
||||
visit(post.replyTo);
|
||||
};
|
||||
|
||||
posts.forEach(visit);
|
||||
return collected;
|
||||
}
|
||||
|
||||
function applyInteractionFlags(
|
||||
posts: FeedPostWithChildren[],
|
||||
likedIds: Set<string>,
|
||||
repostedIds: Set<string>
|
||||
): FeedPostWithChildren[] {
|
||||
return posts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedIds.has(post.id),
|
||||
isReposted: repostedIds.has(post.id),
|
||||
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
|
||||
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
|
||||
}));
|
||||
}
|
||||
|
||||
function getPostTimestamp(post: { createdAt?: string | Date }) {
|
||||
if (!post.createdAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return new Date(post.createdAt).getTime();
|
||||
}
|
||||
|
||||
async function getMixedProfileCursorDate(cursor: string | null) {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cursor.startsWith('swarm-repost:')) {
|
||||
const repostRow = await db.query.userSwarmReposts.findFirst({
|
||||
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
|
||||
});
|
||||
return repostRow?.repostedAt ?? null;
|
||||
}
|
||||
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
return cursorPost?.createdAt ?? null;
|
||||
}
|
||||
|
||||
async function populateViewerLikeState(
|
||||
remotePosts: any[],
|
||||
domain: string
|
||||
remotePosts: any[]
|
||||
) {
|
||||
if (!remotePosts.length) {
|
||||
return remotePosts;
|
||||
@@ -34,20 +179,32 @@ async function populateViewerLikeState(
|
||||
return remotePosts;
|
||||
}
|
||||
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
|
||||
const allRemotePosts = collectNestedPosts(remotePosts as FeedPostWithChildren[]);
|
||||
const swarmTargets = allRemotePosts
|
||||
.filter((post) => post.id.startsWith('swarm:') && post.originalPostId && post.nodeDomain)
|
||||
.map((post) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain!,
|
||||
originalPostId: post.originalPostId!,
|
||||
}));
|
||||
|
||||
const likedIds = await getViewerSwarmLikedPostIds(
|
||||
remotePosts.map((post) => ({
|
||||
id: `swarm:${domain}:${post.originalPostId}`,
|
||||
nodeDomain: domain,
|
||||
originalPostId: post.originalPostId,
|
||||
})),
|
||||
swarmTargets,
|
||||
viewer.handle,
|
||||
nodeDomain
|
||||
);
|
||||
const repostedIds = await getViewerSwarmRepostedPostIds(
|
||||
swarmTargets,
|
||||
viewer.id
|
||||
);
|
||||
|
||||
return remotePosts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
|
||||
}));
|
||||
return applyInteractionFlags(
|
||||
remotePosts as FeedPostWithChildren[],
|
||||
likedIds,
|
||||
repostedIds
|
||||
);
|
||||
} catch {
|
||||
return remotePosts;
|
||||
}
|
||||
@@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const cursor = searchParams.get('cursor');
|
||||
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const fetchRemotePostsRoute = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/users/${remote.handle}/posts?limit=${limit}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
return NextResponse.json({
|
||||
posts: await populateViewerLikeState(mappedPosts),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
@@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
linkPreviewType: post.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||
linkPreviewMedia: post.linkPreviewMedia || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts), nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
@@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user) {
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
@@ -135,39 +321,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
|
||||
}
|
||||
|
||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
|
||||
if (profileData?.posts) {
|
||||
const profile = profileData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const remotePosts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
originalPostId: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
return await fetchRemotePostsRoute();
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
@@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
// Get user's posts with cursor-based pagination
|
||||
const cursorDate = await getMixedProfileCursorDate(cursor);
|
||||
let whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId)
|
||||
);
|
||||
|
||||
// If cursor provided, get posts older than the cursor
|
||||
if (cursor) {
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
if (cursorPost) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId),
|
||||
lt(posts.createdAt, cursorPost.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
if (cursorDate) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId),
|
||||
lt(posts.createdAt, cursorDate)
|
||||
);
|
||||
}
|
||||
|
||||
let userPosts: any[] = await db.query.posts.findMany({
|
||||
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: whereConditions,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
with: userPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
limit: cursor ? limit : limit * 2,
|
||||
});
|
||||
|
||||
const swarmRepostWhere = cursorDate
|
||||
? and(
|
||||
eq(userSwarmReposts.userId, user.id),
|
||||
lt(userSwarmReposts.repostedAt, cursorDate)
|
||||
)
|
||||
: eq(userSwarmReposts.userId, user.id);
|
||||
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
|
||||
where: swarmRepostWhere,
|
||||
orderBy: [desc(userSwarmReposts.repostedAt)],
|
||||
limit: cursor ? limit : limit * 2,
|
||||
});
|
||||
let userPosts: any[] = [
|
||||
...localPosts,
|
||||
...swarmRepostRows.map((row) => mapUserSwarmRepostToFeedPost(row, user)),
|
||||
]
|
||||
.sort((a, b) => getPostTimestamp(b) - getPostTimestamp(a))
|
||||
.slice(0, limit);
|
||||
|
||||
// Populate isLiked and isReposted for authenticated users
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
@@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
|
||||
if (session?.user && userPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = userPosts.map(p => p.id).filter(Boolean);
|
||||
const allProfilePosts = collectNestedPosts(userPosts as FeedPostWithChildren[]);
|
||||
const localPostIds: string[] = [];
|
||||
const swarmTargets: Array<{ id: string; nodeDomain: string; originalPostId: string }> = [];
|
||||
|
||||
if (postIds.length > 0) {
|
||||
for (const post of allProfilePosts) {
|
||||
if (post.id.startsWith('swarm:') && post.nodeDomain && post.originalPostId) {
|
||||
swarmTargets.push({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
});
|
||||
} else if (!post.id.startsWith('swarm-repost:')) {
|
||||
localPostIds.push(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
const likedPostIds = new Set<string>();
|
||||
const repostedPostIds = new Set<string>();
|
||||
|
||||
if (localPostIds.length > 0) {
|
||||
const viewerLikes = await db.query.likes.findMany({
|
||||
where: and(
|
||||
eq(likes.userId, viewer.id),
|
||||
inArray(likes.postId, postIds)
|
||||
inArray(likes.postId, localPostIds)
|
||||
),
|
||||
});
|
||||
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
|
||||
viewerLikes.forEach((like) => likedPostIds.add(like.postId));
|
||||
|
||||
const viewerReposts = await db.query.posts.findMany({
|
||||
where: and(
|
||||
eq(posts.userId, viewer.id),
|
||||
inArray(posts.repostOfId, postIds),
|
||||
inArray(posts.repostOfId, localPostIds),
|
||||
eq(posts.isRemoved, false)
|
||||
),
|
||||
});
|
||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||
|
||||
userPosts = userPosts.map(p => ({
|
||||
...p,
|
||||
isLiked: likedPostIds.has(p.id),
|
||||
isReposted: repostedPostIds.has(p.id),
|
||||
}));
|
||||
viewerReposts.forEach((repost) => {
|
||||
if (repost.repostOfId) {
|
||||
repostedPostIds.add(repost.repostOfId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (swarmTargets.length > 0) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const likedIds = await getViewerSwarmLikedPostIds(
|
||||
swarmTargets.map((post) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
})),
|
||||
viewer.handle,
|
||||
nodeDomain
|
||||
);
|
||||
likedIds.forEach((id) => likedPostIds.add(id));
|
||||
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
const repostedIds = await getViewerSwarmRepostedPostIds(swarmTargets, viewer.id);
|
||||
repostedIds.forEach((id) => repostedPostIds.add(id));
|
||||
}
|
||||
|
||||
userPosts = applyInteractionFlags(
|
||||
userPosts as FeedPostWithChildren[],
|
||||
likedPostIds,
|
||||
repostedPostIds
|
||||
) as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error populating interaction flags:', error);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users } from '@/db';
|
||||
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const replyRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const cursor = searchParams.get('cursor');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
const fetchRemoteReplies = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/replies?limit=${limit}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
const viewer = session?.user;
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
const repostedIds = viewer
|
||||
? await getViewerSwarmRepostedPostIds(
|
||||
mappedPosts.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.originalPostId || post.id.split(':').pop(),
|
||||
})),
|
||||
viewer.id
|
||||
)
|
||||
: new Set<string>();
|
||||
return NextResponse.json({
|
||||
posts: mappedPosts.map((post: any) => ({
|
||||
...post,
|
||||
isReposted: repostedIds.has(post.id),
|
||||
})),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteReplies();
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteReplies();
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
|
||||
);
|
||||
|
||||
if (cursor) {
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
if (cursorPost) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
|
||||
lt(posts.createdAt, cursorPost.createdAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let replyPosts: any[] = await db.query.posts.findMany({
|
||||
where: whereConditions,
|
||||
with: replyRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
|
||||
if (session?.user && replyPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = replyPosts.map((post) => post.id).filter(Boolean);
|
||||
|
||||
const viewerLikes = postIds.length > 0
|
||||
? await db.query.likes.findMany({
|
||||
where: and(
|
||||
eq(likes.userId, viewer.id),
|
||||
inArray(likes.postId, postIds),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
const likedPostIds = new Set(viewerLikes.map((like) => like.postId));
|
||||
|
||||
const viewerReposts = postIds.length > 0
|
||||
? await db.query.posts.findMany({
|
||||
where: and(
|
||||
eq(posts.userId, viewer.id),
|
||||
inArray(posts.repostOfId, postIds),
|
||||
eq(posts.isRemoved, false),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId));
|
||||
|
||||
replyPosts = replyPosts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedPostIds.has(post.id),
|
||||
isReposted: repostedPostIds.has(post.id),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: replyPosts,
|
||||
nextCursor: replyPosts.length === limit ? replyPosts[replyPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user replies error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get replies' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,15 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
||||
if (profileData?.profile) {
|
||||
const profile = profileData.profile;
|
||||
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
|
||||
const normalizedBotOwnerHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.includes('@')
|
||||
? rawBotOwnerHandle
|
||||
: `${rawBotOwnerHandle}@${remoteDomain}`
|
||||
: null;
|
||||
const botOwnerLocalHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.split('@')[0]
|
||||
: null;
|
||||
|
||||
// CACHE: Upsert the remote user into our local database
|
||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||
@@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
||||
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
|
||||
handle: normalizedBotOwnerHandle,
|
||||
displayName: botOwnerLocalHandle,
|
||||
avatarUrl: null,
|
||||
} : null,
|
||||
did: profile.did,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function EditBotPage() {
|
||||
systemPrompt: '',
|
||||
llmProvider: 'openai',
|
||||
llmModel: 'gpt-4',
|
||||
llmEndpoint: '',
|
||||
llmApiKey: '',
|
||||
autonomousMode: false,
|
||||
|
||||
@@ -137,6 +138,7 @@ export default function EditBotPage() {
|
||||
systemPrompt: personalityConfig.systemPrompt || '',
|
||||
llmProvider: bot.llmProvider || 'openai',
|
||||
llmModel: bot.llmModel || 'gpt-4',
|
||||
llmEndpoint: bot.llmEndpoint || '',
|
||||
llmApiKey: '', // Don't pre-fill API key for security
|
||||
autonomousMode: bot.autonomousMode || false,
|
||||
postingFrequency,
|
||||
@@ -288,6 +290,7 @@ export default function EditBotPage() {
|
||||
},
|
||||
llmProvider: formData.llmProvider,
|
||||
llmModel: formData.llmModel,
|
||||
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : null,
|
||||
autonomousMode: formData.autonomousMode,
|
||||
schedule: formData.autonomousMode ? {
|
||||
type: 'interval',
|
||||
@@ -482,9 +485,29 @@ export default function EditBotPage() {
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="custom">Custom (OpenAI Compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.llmProvider === 'custom' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
OpenAI-Compatible Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.llmEndpoint}
|
||||
onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })}
|
||||
className="input"
|
||||
placeholder="https://api.example.com/v1/chat/completions"
|
||||
required
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Enter the full chat completions endpoint for a public OpenAI-compatible API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Model
|
||||
@@ -850,22 +873,23 @@ export default function EditBotPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Posting Frequency
|
||||
</label>
|
||||
<select
|
||||
value={formData.postingFrequency}
|
||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||
className="input"
|
||||
disabled={!formData.autonomousMode}
|
||||
>
|
||||
<option value="every_2_hours">Every 2 Hours</option>
|
||||
<option value="every_4_hours">Every 4 Hours</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
<option value="daily">Once Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.autonomousMode && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Posting Frequency
|
||||
</label>
|
||||
<select
|
||||
value={formData.postingFrequency}
|
||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="every_2_hours">Every 2 Hours</option>
|
||||
<option value="every_4_hours">Every 4 Hours</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
<option value="daily">Once Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
+41
-18
@@ -52,6 +52,7 @@ export default function NewBotPage() {
|
||||
systemPrompt: '',
|
||||
llmProvider: 'openai',
|
||||
llmModel: 'gpt-4',
|
||||
llmEndpoint: '',
|
||||
llmApiKey: '',
|
||||
autonomousMode: false,
|
||||
postingFrequency: 'every_4_hours',
|
||||
@@ -88,6 +89,7 @@ export default function NewBotPage() {
|
||||
...prev,
|
||||
llmProvider: lastBot.llmProvider || 'openai',
|
||||
llmModel: lastBot.llmModel || 'gpt-4',
|
||||
llmEndpoint: lastBot.llmEndpoint || '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -237,6 +239,7 @@ export default function NewBotPage() {
|
||||
},
|
||||
llmProvider: formData.llmProvider,
|
||||
llmModel: formData.llmModel,
|
||||
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : undefined,
|
||||
llmApiKey: formData.llmApiKey,
|
||||
autonomousMode: formData.autonomousMode,
|
||||
schedule: formData.autonomousMode ? {
|
||||
@@ -430,9 +433,28 @@ export default function NewBotPage() {
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="custom">Custom (OpenAI Compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.llmProvider === 'custom' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
OpenAI-Compatible Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.llmEndpoint}
|
||||
onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })}
|
||||
className="input"
|
||||
placeholder="https://api.example.com/v1/chat/completions"
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Enter the full chat completions endpoint for a public OpenAI-compatible API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Model
|
||||
@@ -445,7 +467,7 @@ export default function NewBotPage() {
|
||||
placeholder="gpt-4"
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
e.g., gpt-4, claude-3-opus, etc.
|
||||
e.g., gpt-4, claude-3-opus, mistral-large, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -782,22 +804,23 @@ export default function NewBotPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Posting Frequency
|
||||
</label>
|
||||
<select
|
||||
value={formData.postingFrequency}
|
||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||
className="input"
|
||||
disabled={!formData.autonomousMode}
|
||||
>
|
||||
<option value="every_2_hours">Every 2 Hours</option>
|
||||
<option value="every_4_hours">Every 4 Hours</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
<option value="daily">Once Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.autonomousMode && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Posting Frequency
|
||||
</label>
|
||||
<select
|
||||
value={formData.postingFrequency}
|
||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="every_2_hours">Every 2 Hours</option>
|
||||
<option value="every_4_hours">Every 4 Hours</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
<option value="daily">Once Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -895,7 +918,7 @@ export default function NewBotPage() {
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
(step === 'identity' && (!formData.name || !formData.handle)) ||
|
||||
(step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
|
||||
(step === 'personality' && (!formData.systemPrompt || !formData.llmModel || !formData.llmApiKey || (formData.llmProvider === 'custom' && !formData.llmEndpoint)))
|
||||
}
|
||||
>
|
||||
Next
|
||||
|
||||
+126
-3
@@ -246,10 +246,18 @@ a.btn-primary:visited {
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
@@ -258,6 +266,8 @@ a.btn-primary:visited {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
border-right: 1px solid var(--border);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layout.hide-right-sidebar .main {
|
||||
@@ -320,6 +330,22 @@ a.btn-primary:visited {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.post.embedded {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.post.embedded:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.post.embedded .post-link-overlay {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Post */
|
||||
.post {
|
||||
padding: 16px;
|
||||
@@ -359,6 +385,34 @@ a.btn-primary:visited {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.repost-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.repost-event-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.repost-event-text a {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.repost-event-copy {
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.repost-embed {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.post-reasons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2402,6 +2456,54 @@ a.btn-primary:visited {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-preview-video video {
|
||||
width: 100%;
|
||||
max-height: 480px;
|
||||
display: block;
|
||||
background: #000;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-preview-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-preview-gallery.compact {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.link-preview-gallery-item {
|
||||
position: relative;
|
||||
background: var(--background-tertiary);
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.link-preview-gallery.compact .link-preview-gallery-item {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.link-preview-gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.link-preview-gallery-more {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link-preview-info {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -2455,22 +2557,43 @@ a.btn-primary:visited {
|
||||
|
||||
.link-preview-card.mini {
|
||||
display: flex;
|
||||
max-height: 80px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-image {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-image img {
|
||||
.link-preview-card.mini .link-preview-image img,
|
||||
.link-preview-card.mini .link-preview-image video {
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
object-fit: cover;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-video {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-video video {
|
||||
height: 100%;
|
||||
min-height: 80px;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-gallery {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-preview-card.mini .link-preview-info {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
@@ -2589,4 +2712,4 @@ a.btn-primary:visited {
|
||||
.layout.hide-right-sidebar .main {
|
||||
max-width: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
+105
-11
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { TriangleAlert, X } from 'lucide-react';
|
||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
@@ -24,7 +24,13 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
interface AuthScreenProps {
|
||||
modal?: boolean;
|
||||
onClose?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -204,7 +210,12 @@ export default function LoginPage() {
|
||||
setImportSuccess(data.message);
|
||||
// Soft navigation to preserve AuthContext/KeyStore state
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
@@ -319,7 +330,12 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
// Soft navigation to preserve AuthContext/KeyStore state
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
// Reset Turnstile on error
|
||||
@@ -332,15 +348,17 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
padding: modal ? '0' : '24px',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: mode === 'register' ? '680px' : '400px',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
|
||||
{nodeInfoLoaded && (
|
||||
@@ -984,10 +1002,86 @@ export default function LoginPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
<Link href="/">← Back to home</Link>
|
||||
</p>
|
||||
{!modal && (
|
||||
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
<Link href="/">← Back to home</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (modal) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.72)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
zIndex: 120,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 'min(760px, 100%)',
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '18px',
|
||||
right: '18px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'rgba(0, 0, 0, 0.78)',
|
||||
color: 'var(--foreground)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
padding: '28px',
|
||||
background: 'rgba(10, 10, 10, 0.98)',
|
||||
boxShadow: '0 30px 90px rgba(0, 0, 0, 0.55)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh' }}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthScreen />;
|
||||
}
|
||||
|
||||
+157
-1
@@ -45,6 +45,20 @@ type Report = {
|
||||
target?: AdminPost | AdminUser | null;
|
||||
};
|
||||
|
||||
type AdminNode = {
|
||||
id: string;
|
||||
domain: string;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
isBlocked: boolean;
|
||||
blockReason?: string | null;
|
||||
blockedAt?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
trustScore?: number | null;
|
||||
isNsfw?: boolean;
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
@@ -52,12 +66,15 @@ const formatDate = (value: string) => {
|
||||
|
||||
export default function ModerationPage() {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports');
|
||||
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'nodes'>('reports');
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [nodes, setNodes] = useState<AdminNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
||||
const [nodeDomain, setNodeDomain] = useState('');
|
||||
const [nodeReason, setNodeReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/me')
|
||||
@@ -105,11 +122,25 @@ export default function ModerationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadNodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/nodes');
|
||||
const data = await res.json();
|
||||
setNodes(data.nodes || []);
|
||||
} catch {
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
if (tab === 'reports') loadReports();
|
||||
if (tab === 'posts') loadPosts();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'nodes') loadNodes();
|
||||
}, [tab, isAdmin, reportStatus]);
|
||||
|
||||
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
|
||||
@@ -147,6 +178,17 @@ export default function ModerationPage() {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleNodeAction = async (action: 'block' | 'unblock', domain: string, reason?: string) => {
|
||||
await fetch('/api/admin/nodes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, domain, reason }),
|
||||
});
|
||||
setNodeDomain('');
|
||||
setNodeReason('');
|
||||
loadNodes();
|
||||
};
|
||||
|
||||
const reportCounts = useMemo(() => {
|
||||
return {
|
||||
open: reports.filter((r) => r.status === 'open').length,
|
||||
@@ -209,6 +251,12 @@ export default function ModerationPage() {
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${tab === 'nodes' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setTab('nodes')}
|
||||
>
|
||||
Nodes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'reports' && (
|
||||
@@ -470,6 +518,114 @@ export default function ModerationPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'nodes' && (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div className="card" style={{ padding: '16px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>Block node</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
|
||||
Blocked nodes cannot deliver swarm interactions, appear in swarm feeds, or exchange chat with this node.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<input
|
||||
value={nodeDomain}
|
||||
onChange={(e) => setNodeDomain(e.target.value)}
|
||||
placeholder="node.example.com"
|
||||
className="input"
|
||||
/>
|
||||
<textarea
|
||||
value={nodeReason}
|
||||
onChange={(e) => setNodeReason(e.target.value)}
|
||||
placeholder="Reason for blocking this node (optional)"
|
||||
className="input"
|
||||
style={{ minHeight: '88px', resize: 'vertical' }}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleNodeAction('block', nodeDomain, nodeReason)}
|
||||
disabled={!nodeDomain.trim()}
|
||||
>
|
||||
Block node
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>Loading nodes...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>No known nodes.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} className="card" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
background: node.isBlocked ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)',
|
||||
color: node.isBlocked ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{node.isBlocked ? 'blocked' : 'allowed'}
|
||||
</span>
|
||||
{!node.isBlocked && !node.isActive && (
|
||||
<span style={{ fontSize: '11px', color: 'var(--foreground-tertiary)' }}>
|
||||
inactive
|
||||
</span>
|
||||
)}
|
||||
{node.isNsfw && (
|
||||
<span style={{ fontSize: '11px', color: 'rgb(245, 158, 11)' }}>
|
||||
NSFW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
|
||||
{node.name || node.domain}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||
{node.domain}
|
||||
</div>
|
||||
{node.description && (
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||
{node.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
|
||||
{node.blockedAt ? `Blocked ${formatDate(node.blockedAt)}` : node.lastSeenAt ? `Last seen ${formatDate(node.lastSeenAt)}` : 'Never seen'}
|
||||
{typeof node.trustScore === 'number' && <span> • Trust {node.trustScore}</span>}
|
||||
</div>
|
||||
{node.blockReason && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginTop: '6px' }}>
|
||||
Reason: {node.blockReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{node.isBlocked ? (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleNodeAction('unblock', node.domain)}>
|
||||
Unblock
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleNodeAction('block', node.domain, window.prompt('Reason (optional):') || '')}
|
||||
>
|
||||
Block
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ interface NotificationActor {
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
interface NotificationTarget {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
nodeDomain?: string | null;
|
||||
isBot?: boolean | null;
|
||||
}
|
||||
|
||||
interface NotificationPost {
|
||||
id: string;
|
||||
content: string;
|
||||
@@ -24,6 +32,7 @@ interface Notification {
|
||||
createdAt: string;
|
||||
readAt: string | null;
|
||||
actor: NotificationActor | null;
|
||||
target: NotificationTarget | null;
|
||||
post: NotificationPost | null;
|
||||
}
|
||||
|
||||
@@ -77,17 +86,28 @@ export default function NotificationsPage() {
|
||||
};
|
||||
|
||||
const getNotificationText = (notification: Notification) => {
|
||||
const targetName = notification.target?.displayName || notification.target?.handle;
|
||||
switch (notification.type) {
|
||||
case 'follow':
|
||||
return 'followed you';
|
||||
return notification.target?.isBot && targetName
|
||||
? `followed your bot ${targetName}`
|
||||
: 'followed you';
|
||||
case 'like':
|
||||
return 'liked your post';
|
||||
return notification.target?.isBot && targetName
|
||||
? `liked a post from ${targetName}`
|
||||
: 'liked your post';
|
||||
case 'repost':
|
||||
return 'reposted your post';
|
||||
return notification.target?.isBot && targetName
|
||||
? `reposted a post from ${targetName}`
|
||||
: 'reposted your post';
|
||||
case 'mention':
|
||||
return 'mentioned you';
|
||||
return notification.target?.isBot && targetName
|
||||
? `mentioned your bot ${targetName}`
|
||||
: 'mentioned you';
|
||||
case 'reply':
|
||||
return 'replied to your post';
|
||||
return notification.target?.isBot && targetName
|
||||
? `replied to a post from ${targetName}`
|
||||
: 'replied to your post';
|
||||
default:
|
||||
return 'interacted with you';
|
||||
}
|
||||
|
||||
+44
-22
@@ -175,7 +175,7 @@ export default function ProfilePage() {
|
||||
if (!repliesCursor || repliesLoadingMore || !user) return;
|
||||
setRepliesLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`);
|
||||
const res = await fetch(`/api/users/${handle}/replies?cursor=${repliesCursor}`);
|
||||
const data = await res.json();
|
||||
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
|
||||
setRepliesCursor(data.nextCursor || null);
|
||||
@@ -266,7 +266,10 @@ export default function ProfilePage() {
|
||||
}, [user, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || !user || currentUser.handle === user.handle) {
|
||||
const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0];
|
||||
const isOwnedBot = Boolean(user?.isBot && currentUser && ownerHandle === currentUser.handle);
|
||||
|
||||
if (!currentUser || !user || currentUser.handle === user.handle || isOwnedBot) {
|
||||
setIsFollowing(false);
|
||||
setIsBlocked(false);
|
||||
return;
|
||||
@@ -314,7 +317,7 @@ export default function ProfilePage() {
|
||||
if (activeTab === 'replies' && user) {
|
||||
setRepliesLoading(true);
|
||||
setRepliesCursor(null);
|
||||
fetch(`/api/posts?type=replies&userId=${user.id}`)
|
||||
fetch(`/api/users/${handle}/replies`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setRepliesPosts(data.posts || []);
|
||||
@@ -323,7 +326,13 @@ export default function ProfilePage() {
|
||||
.catch(() => setRepliesPosts([]))
|
||||
.finally(() => setRepliesLoading(false));
|
||||
}
|
||||
}, [activeTab, handle, user]);
|
||||
}, [activeTab, handle, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isBot && activeTab === 'following') {
|
||||
setActiveTab('posts');
|
||||
}
|
||||
}, [user?.isBot, activeTab]);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!currentUser) return;
|
||||
@@ -447,6 +456,19 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
const isOwnProfile = currentUser?.handle === user.handle;
|
||||
const botOwner = user.botOwner as BotOwner | null | undefined;
|
||||
const botOwnerLocalHandle = botOwner?.handle?.replace(/^@/, '').split('@')[0] || null;
|
||||
const isOwnedBotProfile = Boolean(
|
||||
user.isBot &&
|
||||
currentUser &&
|
||||
botOwner &&
|
||||
(botOwner.id === currentUser.id || botOwnerLocalHandle === currentUser.handle)
|
||||
);
|
||||
const showFollowingUi = !user.isBot;
|
||||
const visibleTabs = (user.isBot
|
||||
? ['posts', 'replies', 'followers'] as const
|
||||
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||
@@ -518,7 +540,8 @@ export default function ProfilePage() {
|
||||
{/* Banner */}
|
||||
<div
|
||||
style={{
|
||||
height: '150px',
|
||||
width: '100%',
|
||||
aspectRatio: '3 / 1',
|
||||
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
@@ -553,7 +576,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{!isOwnProfile && currentUser && (
|
||||
{!isOwnProfile && !isOwnedBotProfile && currentUser && (
|
||||
<>
|
||||
{!isBlocked && (
|
||||
<button
|
||||
@@ -713,18 +736,20 @@ export default function ProfilePage() {
|
||||
<strong>{user.followersCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('following')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followingCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
||||
</button>
|
||||
{showFollowingUi && (
|
||||
<button
|
||||
onClick={() => setActiveTab('following')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followingCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -807,10 +832,7 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||
{(user?.isBot
|
||||
? ['posts', 'replies', 'followers', 'following'] as const
|
||||
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
||||
).map(tab => (
|
||||
{visibleTabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
|
||||
Reference in New Issue
Block a user