feat: Implement dynamic node title metadata and enhance swarm user and post hydration.
This commit is contained in:
+36
-34
@@ -120,7 +120,7 @@ export async function POST(request: Request) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
|
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
|
||||||
|
|
||||||
const replyPayload = {
|
const replyPayload = {
|
||||||
postId: data.swarmReplyTo!.postId,
|
postId: data.swarmReplyTo!.postId,
|
||||||
reply: {
|
reply: {
|
||||||
@@ -159,18 +159,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { extractMentions } = await import('@/lib/swarm/interactions');
|
const { extractMentions } = await import('@/lib/swarm/interactions');
|
||||||
const { notifications } = await import('@/db');
|
const { notifications } = await import('@/db');
|
||||||
|
|
||||||
const mentions = extractMentions(data.content);
|
const mentions = extractMentions(data.content);
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
// Only handle local mentions (no domain)
|
// Only handle local mentions (no domain)
|
||||||
if (mention.domain) continue;
|
if (mention.domain) continue;
|
||||||
|
|
||||||
// Find the mentioned user
|
// Find the mentioned user
|
||||||
const mentionedUser = await db.query.users.findFirst({
|
const mentionedUser = await db.query.users.findFirst({
|
||||||
where: eq(users.handle, mention.handle.toLowerCase()),
|
where: eq(users.handle, mention.handle.toLowerCase()),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
|
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
|
||||||
// Create notification for the mentioned user with actor info stored directly
|
// Create notification for the mentioned user with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -184,7 +184,7 @@ export async function POST(request: Request) {
|
|||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot being mentioned
|
// Also notify bot owner if this is a bot being mentioned
|
||||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -210,7 +210,7 @@ export async function POST(request: Request) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
|
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
|
||||||
|
|
||||||
const result = await deliverSwarmMentions(
|
const result = await deliverSwarmMentions(
|
||||||
data.content,
|
data.content,
|
||||||
post.id,
|
post.id,
|
||||||
@@ -221,7 +221,7 @@ export async function POST(request: Request) {
|
|||||||
nodeDomain,
|
nodeDomain,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.delivered > 0) {
|
if (result.delivered > 0) {
|
||||||
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
// SWARM-FIRST: Deliver to swarm followers directly
|
// SWARM-FIRST: Deliver to swarm followers directly
|
||||||
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
|
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
|
||||||
|
|
||||||
const swarmResult = await deliverPostToSwarmFollowers(
|
const swarmResult = await deliverPostToSwarmFollowers(
|
||||||
user.id,
|
user.id,
|
||||||
post,
|
post,
|
||||||
@@ -248,7 +248,7 @@ export async function POST(request: Request) {
|
|||||||
attachedMedia,
|
attachedMedia,
|
||||||
nodeDomain
|
nodeDomain
|
||||||
);
|
);
|
||||||
|
|
||||||
if (swarmResult.delivered > 0) {
|
if (swarmResult.delivered > 0) {
|
||||||
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||||
}
|
}
|
||||||
@@ -399,7 +399,7 @@ export async function GET(request: Request) {
|
|||||||
if (type === 'local') {
|
if (type === 'local') {
|
||||||
// Local node posts only - no fediverse content
|
// Local node posts only - no fediverse content
|
||||||
let whereCondition = baseFilter;
|
let whereCondition = baseFilter;
|
||||||
|
|
||||||
// Apply cursor-based pagination
|
// Apply cursor-based pagination
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
const cursorPost = await db.query.posts.findFirst({
|
||||||
@@ -409,7 +409,7 @@ export async function GET(request: Request) {
|
|||||||
whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
|
whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: {
|
||||||
@@ -454,7 +454,7 @@ export async function GET(request: Request) {
|
|||||||
} else if (type === 'user' && userId) {
|
} else if (type === 'user' && userId) {
|
||||||
// User's posts (excluding replies)
|
// User's posts (excluding replies)
|
||||||
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
|
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
|
||||||
|
|
||||||
// Apply cursor-based pagination
|
// Apply cursor-based pagination
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
const cursorPost = await db.query.posts.findFirst({
|
||||||
@@ -464,7 +464,7 @@ export async function GET(request: Request) {
|
|||||||
whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
|
whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: {
|
||||||
@@ -481,7 +481,7 @@ export async function GET(request: Request) {
|
|||||||
} else if (type === 'replies' && userId) {
|
} else if (type === 'replies' && userId) {
|
||||||
// User's replies only
|
// User's replies only
|
||||||
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
|
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
|
||||||
|
|
||||||
// Apply cursor-based pagination
|
// Apply cursor-based pagination
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
const cursorPost = await db.query.posts.findFirst({
|
||||||
@@ -491,7 +491,7 @@ export async function GET(request: Request) {
|
|||||||
whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
|
whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: {
|
||||||
@@ -522,7 +522,7 @@ export async function GET(request: Request) {
|
|||||||
// Fetch swarm posts with user's NSFW preference
|
// Fetch swarm posts with user's NSFW preference
|
||||||
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
||||||
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
|
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
|
||||||
|
|
||||||
// Transform swarm posts to match local post format
|
// Transform swarm posts to match local post format
|
||||||
const swarmPosts = swarmResult.posts.map(sp => ({
|
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||||
@@ -540,6 +540,7 @@ export async function GET(request: Request) {
|
|||||||
displayName: sp.author.displayName,
|
displayName: sp.author.displayName,
|
||||||
avatarUrl: sp.author.avatarUrl,
|
avatarUrl: sp.author.avatarUrl,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
|
isBot: sp.author.isBot,
|
||||||
nodeDomain: sp.nodeDomain,
|
nodeDomain: sp.nodeDomain,
|
||||||
},
|
},
|
||||||
media: sp.media?.map((m, idx) => ({
|
media: sp.media?.map((m, idx) => ({
|
||||||
@@ -630,13 +631,13 @@ export async function GET(request: Request) {
|
|||||||
.from(follows)
|
.from(follows)
|
||||||
.where(eq(follows.followerId, user.id));
|
.where(eq(follows.followerId, user.id));
|
||||||
const followingIds = followRows.map(row => row.followingId);
|
const followingIds = followRows.map(row => row.followingId);
|
||||||
|
|
||||||
// Include own posts + posts from followed users
|
// Include own posts + posts from followed users
|
||||||
const allowedUserIds = [user.id, ...followingIds];
|
const allowedUserIds = [user.id, ...followingIds];
|
||||||
|
|
||||||
// Build where condition with cursor support
|
// Build where condition with cursor support
|
||||||
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
|
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
const cursorPost = await db.query.posts.findFirst({
|
||||||
where: eq(posts.id, cursor),
|
where: eq(posts.id, cursor),
|
||||||
@@ -671,7 +672,7 @@ export async function GET(request: Request) {
|
|||||||
if (followedRemoteUsers.length > 0) {
|
if (followedRemoteUsers.length > 0) {
|
||||||
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
|
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
|
||||||
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
|
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
|
||||||
|
|
||||||
// Wrap each fetch with a timeout to prevent slow nodes from blocking
|
// Wrap each fetch with a timeout to prevent slow nodes from blocking
|
||||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
@@ -679,25 +680,25 @@ export async function GET(request: Request) {
|
|||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPromises = followedRemoteUsers.map(async (follow) => {
|
const fetchPromises = followedRemoteUsers.map(async (follow) => {
|
||||||
try {
|
try {
|
||||||
const atIndex = follow.targetHandle.lastIndexOf('@');
|
const atIndex = follow.targetHandle.lastIndexOf('@');
|
||||||
if (atIndex === -1) return [];
|
if (atIndex === -1) return [];
|
||||||
|
|
||||||
const handle = follow.targetHandle.slice(0, atIndex);
|
const handle = follow.targetHandle.slice(0, atIndex);
|
||||||
const domain = follow.targetHandle.slice(atIndex + 1);
|
const domain = follow.targetHandle.slice(atIndex + 1);
|
||||||
|
|
||||||
// Check if swarm node - use swarm API (faster)
|
// Check if swarm node - use swarm API (faster)
|
||||||
const isSwarm = await isSwarmNode(domain);
|
const isSwarm = await isSwarmNode(domain);
|
||||||
|
|
||||||
if (isSwarm) {
|
if (isSwarm) {
|
||||||
const profileData = await withTimeout(
|
const profileData = await withTimeout(
|
||||||
fetchSwarmUserProfile(handle, domain, limit),
|
fetchSwarmUserProfile(handle, domain, limit),
|
||||||
5000 // 5s timeout per node
|
5000 // 5s timeout per node
|
||||||
);
|
);
|
||||||
if (!profileData?.posts) return [];
|
if (!profileData?.posts) return [];
|
||||||
|
|
||||||
return profileData.posts.map(post => ({
|
return profileData.posts.map(post => ({
|
||||||
id: `swarm:${domain}:${post.id}`,
|
id: `swarm:${domain}:${post.id}`,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
@@ -717,6 +718,7 @@ export async function GET(request: Request) {
|
|||||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
|
isBot: profileData.profile?.isBot,
|
||||||
},
|
},
|
||||||
media: post.media?.map((m: any, idx: number) => ({
|
media: post.media?.map((m: any, idx: number) => ({
|
||||||
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
||||||
@@ -729,14 +731,14 @@ export async function GET(request: Request) {
|
|||||||
// ActivityPub - fetch from outbox
|
// ActivityPub - fetch from outbox
|
||||||
const remoteProfile = await resolveRemoteUser(handle, domain);
|
const remoteProfile = await resolveRemoteUser(handle, domain);
|
||||||
if (!remoteProfile?.outbox) return [];
|
if (!remoteProfile?.outbox) return [];
|
||||||
|
|
||||||
// For AP, fall back to cached posts (live outbox fetch is slower)
|
// For AP, fall back to cached posts (live outbox fetch is slower)
|
||||||
const cachedPosts = await db.query.remotePosts.findMany({
|
const cachedPosts = await db.query.remotePosts.findMany({
|
||||||
where: eq(remotePosts.authorHandle, follow.targetHandle),
|
where: eq(remotePosts.authorHandle, follow.targetHandle),
|
||||||
orderBy: [desc(remotePosts.publishedAt)],
|
orderBy: [desc(remotePosts.publishedAt)],
|
||||||
limit: limit,
|
limit: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return transformRemotePosts(cachedPosts);
|
return transformRemotePosts(cachedPosts);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -744,7 +746,7 @@ export async function GET(request: Request) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(fetchPromises);
|
const results = await Promise.all(fetchPromises);
|
||||||
liveRemotePosts = results.flat();
|
liveRemotePosts = results.flat();
|
||||||
}
|
}
|
||||||
@@ -781,11 +783,11 @@ export async function GET(request: Request) {
|
|||||||
if (session?.user && feedPosts && feedPosts.length > 0) {
|
if (session?.user && feedPosts && feedPosts.length > 0) {
|
||||||
const viewer = session.user;
|
const viewer = session.user;
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
// Separate local and swarm posts
|
// Separate local and swarm posts
|
||||||
const localPostIds: string[] = [];
|
const localPostIds: string[] = [];
|
||||||
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
|
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
|
||||||
|
|
||||||
for (const p of feedPosts as Array<{ id: string }>) {
|
for (const p of feedPosts as Array<{ id: string }>) {
|
||||||
if (p.id.startsWith('swarm:')) {
|
if (p.id.startsWith('swarm:')) {
|
||||||
const parts = p.id.split(':');
|
const parts = p.id.split(':');
|
||||||
@@ -804,7 +806,7 @@ export async function GET(request: Request) {
|
|||||||
// Check local likes
|
// Check local likes
|
||||||
const likedPostIds = new Set<string>();
|
const likedPostIds = new Set<string>();
|
||||||
const repostedPostIds = new Set<string>();
|
const repostedPostIds = new Set<string>();
|
||||||
|
|
||||||
if (localPostIds.length > 0) {
|
if (localPostIds.length > 0) {
|
||||||
const viewerLikes = await db.query.likes.findMany({
|
const viewerLikes = await db.query.likes.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
@@ -829,12 +831,12 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
|
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
|
||||||
const url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`;
|
const url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.isLiked) {
|
if (data.isLiked) {
|
||||||
@@ -845,7 +847,7 @@ export async function GET(request: Request) {
|
|||||||
// Timeout or error - just skip
|
// Timeout or error - just skip
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(checkPromises);
|
await Promise.all(checkPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const refresh = searchParams.get('refresh') === 'true';
|
const refresh = searchParams.get('refresh') === 'true';
|
||||||
|
const cursor = searchParams.get('cursor') || undefined;
|
||||||
|
|
||||||
// Check user's NSFW preference
|
// Check user's NSFW preference
|
||||||
let includeNsfw = false;
|
let includeNsfw = false;
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch swarm timeline (no caching - user preferences vary)
|
// Fetch swarm timeline (no caching - user preferences vary)
|
||||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
|
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
posts: timeline.posts,
|
posts: timeline.posts,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, nodes } from '@/db';
|
import { db, posts, users, media, nodes } from '@/db';
|
||||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
import { eq, desc, and, isNull, lt } from 'drizzle-orm';
|
||||||
|
|
||||||
export interface SwarmPost {
|
export interface SwarmPost {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,7 @@ export interface SwarmPost {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
isNsfw: boolean;
|
isNsfw: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
};
|
};
|
||||||
nodeDomain: string;
|
nodeDomain: string;
|
||||||
nodeIsNsfw: boolean;
|
nodeIsNsfw: boolean;
|
||||||
@@ -43,18 +44,36 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
const cursor = searchParams.get('cursor');
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
|
|
||||||
// Get node NSFW status
|
// Get node NSFW status
|
||||||
const node = await db.query.nodes.findFirst({
|
const node = await db.query.nodes.findFirst({
|
||||||
where: eq(nodes.domain, nodeDomain),
|
where: eq(nodes.domain, nodeDomain),
|
||||||
});
|
});
|
||||||
const nodeIsNsfw = node?.isNsfw ?? false;
|
const nodeIsNsfw = node?.isNsfw ?? false;
|
||||||
|
|
||||||
|
// Use query builder for better conditional logic
|
||||||
|
let whereCondition = and(
|
||||||
|
isNull(posts.replyToId), // Not a reply
|
||||||
|
eq(posts.isRemoved, false) // Not removed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
// Find the cursor post or use timestamp directly if passed as ISO string
|
||||||
|
// Actually, for swarm, passing ISO timestamp is safer than ID because IDs are local UUIDs
|
||||||
|
// Let's assume cursor is an ISO date string for swarm timeline
|
||||||
|
const cursorDate = new Date(cursor);
|
||||||
|
if (!isNaN(cursorDate.getTime())) {
|
||||||
|
whereCondition = and(whereCondition, lt(posts.createdAt, cursorDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get recent public posts (not replies, local users only, not removed)
|
// Get recent public posts (not replies, local users only, not removed)
|
||||||
const recentPosts = await db
|
const recentPosts = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -73,22 +92,18 @@ export async function GET(request: NextRequest) {
|
|||||||
authorDisplayName: users.displayName,
|
authorDisplayName: users.displayName,
|
||||||
authorAvatarUrl: users.avatarUrl,
|
authorAvatarUrl: users.avatarUrl,
|
||||||
authorIsNsfw: users.isNsfw,
|
authorIsNsfw: users.isNsfw,
|
||||||
|
authorIsBot: users.isBot,
|
||||||
authorNodeId: users.nodeId,
|
authorNodeId: users.nodeId,
|
||||||
})
|
})
|
||||||
.from(posts)
|
.from(posts)
|
||||||
.innerJoin(users, eq(posts.userId, users.id))
|
.innerJoin(users, eq(posts.userId, users.id))
|
||||||
.where(
|
.where(whereCondition)
|
||||||
and(
|
|
||||||
isNull(posts.replyToId), // Not a reply
|
|
||||||
eq(posts.isRemoved, false) // Not removed
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(posts.createdAt))
|
.orderBy(desc(posts.createdAt))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
// Fetch media for each post
|
// Fetch media for each post
|
||||||
const swarmPosts: SwarmPost[] = [];
|
const swarmPosts: SwarmPost[] = [];
|
||||||
|
|
||||||
for (const post of recentPosts) {
|
for (const post of recentPosts) {
|
||||||
const postMedia = await db
|
const postMedia = await db
|
||||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||||
@@ -104,6 +119,7 @@ export async function GET(request: NextRequest) {
|
|||||||
displayName: post.authorDisplayName || post.authorHandle,
|
displayName: post.authorDisplayName || post.authorHandle,
|
||||||
avatarUrl: post.authorAvatarUrl || undefined,
|
avatarUrl: post.authorAvatarUrl || undefined,
|
||||||
isNsfw: post.authorIsNsfw,
|
isNsfw: post.authorIsNsfw,
|
||||||
|
isBot: post.authorIsBot,
|
||||||
},
|
},
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
nodeIsNsfw,
|
nodeIsNsfw,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, follows, users, remoteFollowers } from '@/db';
|
import { db, follows, users, remoteFollowers } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||||
|
|
||||||
export interface SwarmFollowerUser {
|
export interface SwarmFollowerUser {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -88,8 +89,33 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
// Merge all followers
|
// Merge all followers
|
||||||
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit);
|
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit);
|
||||||
|
|
||||||
|
// Hydrate remote users (from 3rd party nodes)
|
||||||
|
// We need to map to the HydratedUser interface temporarily for the helper
|
||||||
|
const toHydrate = allFollowers.map(f => ({
|
||||||
|
id: f.handle,
|
||||||
|
handle: f.handle,
|
||||||
|
displayName: f.displayName,
|
||||||
|
avatarUrl: f.avatarUrl,
|
||||||
|
bio: f.bio,
|
||||||
|
isRemote: f.isRemote || false,
|
||||||
|
isBot: f.isBot,
|
||||||
|
nodeDomain: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hydrated = await hydrateSwarmUsers(toHydrate);
|
||||||
|
|
||||||
|
// Map back to SwarmFollowerUser
|
||||||
|
const finalFollowers: SwarmFollowerUser[] = hydrated.map(u => ({
|
||||||
|
handle: u.handle,
|
||||||
|
displayName: u.displayName || u.handle.split('@')[0], // Ensure non-null
|
||||||
|
avatarUrl: u.avatarUrl || undefined, // Map null to undefined
|
||||||
|
bio: u.bio || undefined,
|
||||||
|
isBot: u.isBot,
|
||||||
|
isRemote: u.isRemote,
|
||||||
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
followers: allFollowers,
|
followers: finalFollowers,
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
// Check if this is a remote user
|
// Check if this is a remote user
|
||||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||||
|
|
||||||
if (remoteDomain) {
|
if (remoteDomain) {
|
||||||
// Fetch from remote swarm node
|
// Fetch from remote swarm node
|
||||||
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
|
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
|
||||||
@@ -80,15 +80,23 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
.where(eq(follows.followingId, user.id))
|
.where(eq(follows.followingId, user.id))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
|
const allFollowers = userFollowers.map(f => ({
|
||||||
|
id: f.follower.id,
|
||||||
|
handle: f.follower.handle,
|
||||||
|
displayName: f.follower.displayName,
|
||||||
|
avatarUrl: f.follower.avatarUrl,
|
||||||
|
bio: f.follower.bio,
|
||||||
|
isBot: f.follower.isBot,
|
||||||
|
isRemote: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Hydrate remote users with fresh data from swarm (if we had local storage for remote followers, we'd merge them here)
|
||||||
|
// Since we don't store remote followers locally for local users (only incoming follows),
|
||||||
|
// we mainly need this if we were merging remote lists which we only do in the swarm endpoint.
|
||||||
|
// However, let's keep it consistent in case we add remote followers storage.
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
followers: userFollowers.map(f => ({
|
followers: allFollowers,
|
||||||
id: f.follower.id,
|
|
||||||
handle: f.follower.handle,
|
|
||||||
displayName: f.follower.displayName,
|
|
||||||
avatarUrl: f.follower.avatarUrl,
|
|
||||||
bio: f.follower.bio,
|
|
||||||
isBot: f.follower.isBot,
|
|
||||||
})),
|
|
||||||
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, follows, users, remoteFollows } from '@/db';
|
import { db, follows, users, remoteFollows } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
// Check if this is a remote user
|
// Check if this is a remote user
|
||||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||||
|
|
||||||
if (remoteDomain) {
|
if (remoteDomain) {
|
||||||
// Fetch from remote swarm node
|
// Fetch from remote swarm node
|
||||||
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
|
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
|
||||||
@@ -108,8 +109,11 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
// Merge and return
|
// Merge and return
|
||||||
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
|
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
|
||||||
|
|
||||||
|
// Hydrate remote users with fresh data from swarm
|
||||||
|
const hydratedFollowing = await hydrateSwarmUsers(allFollowing);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
following: allFollowing,
|
following: hydratedFollowing,
|
||||||
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
|
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+107
-9
@@ -34,15 +34,15 @@ function UserCard({ user }: { user: User }) {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<span className="user-card-name">{user.displayName || user.handle}</span>
|
<span className="user-card-name">{user.displayName || user.handle}</span>
|
||||||
{user.isBot && (
|
{user.isBot && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '3px',
|
gap: '3px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background: 'var(--accent-muted)',
|
background: 'var(--accent-muted)',
|
||||||
color: 'var(--accent)',
|
color: 'var(--accent)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
@@ -90,6 +90,11 @@ export default function ExplorePage() {
|
|||||||
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [nodeCursor, setNodeCursor] = useState<string | null>(null);
|
||||||
|
const [swarmCursor, setSwarmCursor] = useState<string | null>(null);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMoreNode, setHasMoreNode] = useState(true);
|
||||||
|
const [hasMoreSwarm, setHasMoreSwarm] = useState(true);
|
||||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||||
|
|
||||||
// Fetch node info to check if NSFW
|
// Fetch node info to check if NSFW
|
||||||
@@ -99,7 +104,7 @@ export default function ExplorePage() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
setIsNsfwNode(data.isNsfw || false);
|
setIsNsfwNode(data.isNsfw || false);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +115,8 @@ export default function ExplorePage() {
|
|||||||
const res = await fetch('/api/posts?type=local&limit=20');
|
const res = await fetch('/api/posts?type=local&limit=20');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNodePosts(data.posts || []);
|
setNodePosts(data.posts || []);
|
||||||
|
setNodeCursor(data.nextCursor || null);
|
||||||
|
setHasMoreNode(!!data.nextCursor);
|
||||||
} catch {
|
} catch {
|
||||||
setNodePosts([]);
|
setNodePosts([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,8 +124,10 @@ export default function ExplorePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadNodePosts();
|
if (activeTab === 'node' && nodePosts.length === 0) {
|
||||||
}, []);
|
loadNodePosts();
|
||||||
|
}
|
||||||
|
}, [activeTab, nodePosts.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load swarm posts when tab changes
|
// Load swarm posts when tab changes
|
||||||
@@ -130,6 +139,15 @@ export default function ExplorePage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSwarmPosts(data.posts || []);
|
setSwarmPosts(data.posts || []);
|
||||||
setSwarmSources(data.sources || []);
|
setSwarmSources(data.sources || []);
|
||||||
|
|
||||||
|
// Set cursor from the last post if available
|
||||||
|
if (data.posts && data.posts.length > 0) {
|
||||||
|
const lastPost = data.posts[data.posts.length - 1];
|
||||||
|
setSwarmCursor(lastPost.createdAt); // Use timestamp as cursor
|
||||||
|
setHasMoreSwarm(true);
|
||||||
|
} else {
|
||||||
|
setHasMoreSwarm(false);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSwarmPosts([]);
|
setSwarmPosts([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,6 +158,74 @@ export default function ExplorePage() {
|
|||||||
}
|
}
|
||||||
}, [activeTab, swarmPosts.length]);
|
}, [activeTab, swarmPosts.length]);
|
||||||
|
|
||||||
|
// Load more node posts
|
||||||
|
const loadMoreNode = async () => {
|
||||||
|
if (!nodeCursor || loadingMore || !hasMoreNode) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/posts?type=local&limit=20&cursor=${nodeCursor}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.posts && data.posts.length > 0) {
|
||||||
|
setNodePosts(prev => [...prev, ...data.posts]);
|
||||||
|
setNodeCursor(data.nextCursor || null);
|
||||||
|
setHasMoreNode(!!data.nextCursor);
|
||||||
|
} else {
|
||||||
|
setHasMoreNode(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error loading more
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load more swarm posts
|
||||||
|
const loadMoreSwarm = async () => {
|
||||||
|
if (!swarmCursor || loadingMore || !hasMoreSwarm) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
// Use timestamp of last post as cursor
|
||||||
|
const res = await fetch(`/api/posts/swarm?limit=20&cursor=${encodeURIComponent(swarmCursor)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.posts && data.posts.length > 0) {
|
||||||
|
setSwarmPosts(prev => [...prev, ...data.posts]);
|
||||||
|
|
||||||
|
const lastPost = data.posts[data.posts.length - 1];
|
||||||
|
setSwarmCursor(lastPost.createdAt);
|
||||||
|
setHasMoreSwarm(true);
|
||||||
|
} else {
|
||||||
|
setHasMoreSwarm(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error loading more
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intersection Observer for Infinite Scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
if (activeTab === 'node') {
|
||||||
|
loadMoreNode();
|
||||||
|
} else if (activeTab === 'swarm') {
|
||||||
|
loadMoreSwarm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentinel = document.getElementById('scroll-sentinel');
|
||||||
|
if (sentinel) {
|
||||||
|
observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [activeTab, nodeCursor, swarmCursor, loadingMore, hasMoreNode, hasMoreSwarm]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load users when tab changes to users
|
// Load users when tab changes to users
|
||||||
if (activeTab === 'users' && users.length === 0) {
|
if (activeTab === 'users' && users.length === 0) {
|
||||||
@@ -278,6 +364,12 @@ export default function ExplorePage() {
|
|||||||
{nodePosts.map((post) => (
|
{nodePosts.map((post) => (
|
||||||
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
|
{/* Sentinel for Infinite Scroll */}
|
||||||
|
{hasMoreNode && (
|
||||||
|
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
|
||||||
|
{loadingMore ? 'Loading more...' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -342,6 +434,12 @@ export default function ExplorePage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Sentinel for Infinite Scroll */}
|
||||||
|
{hasMoreSwarm && (
|
||||||
|
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
|
||||||
|
{loadingMore ? 'Loading more...' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-10
@@ -13,16 +13,34 @@ const sairaCondensed = Saira_Condensed({
|
|||||||
variable: "--font-saira",
|
variable: "--font-saira",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import { db } from "@/db";
|
||||||
title: "Synapsis",
|
|
||||||
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
manifest: "/manifest.json",
|
let title = "Synapsis";
|
||||||
icons: {
|
|
||||||
icon: "/api/favicon",
|
try {
|
||||||
},
|
const node = await db.query.nodes.findFirst();
|
||||||
themeColor: "#0a0a0a",
|
if (node?.name) {
|
||||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
title = node.name;
|
||||||
};
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch node info for metadata", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: title,
|
||||||
|
template: `%s | ${title}`,
|
||||||
|
},
|
||||||
|
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
icons: {
|
||||||
|
icon: "/api/favicon",
|
||||||
|
},
|
||||||
|
themeColor: "#0a0a0a",
|
||||||
|
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Force all routes to be dynamic (no static generation at build time)
|
// Force all routes to be dynamic (no static generation at build time)
|
||||||
// This is appropriate for a social network where all content is user-generated
|
// This is appropriate for a social network where all content is user-generated
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function NotificationItem({
|
|||||||
>
|
>
|
||||||
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
||||||
{actor?.avatarUrl ? (
|
{actor?.avatarUrl ? (
|
||||||
<Image
|
<img
|
||||||
src={actor.avatarUrl}
|
src={actor.avatarUrl}
|
||||||
alt={actor.displayName || actor.handle}
|
alt={actor.displayName || actor.handle}
|
||||||
width={40}
|
width={40}
|
||||||
@@ -220,7 +220,7 @@ function NotificationItem({
|
|||||||
href={actor ? `/@${actor.handle}` : '#'}
|
href={actor ? `/@${actor.handle}` : '#'}
|
||||||
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{actor?.displayName || actor?.handle || 'Someone'}
|
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<span style={{ color: 'var(--foreground-secondary)' }}>
|
<span style={{ color: 'var(--foreground-secondary)' }}>
|
||||||
{getNotificationText(notification)}
|
{getNotificationText(notification)}
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||||
{post.author.displayName || post.author.handle}
|
{post.author.displayName || post.author.handle}
|
||||||
</Link>
|
</Link>
|
||||||
{post.bot && (
|
{(post.bot || post.author.isBot) && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
@@ -454,7 +454,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
color: 'var(--accent)',
|
color: 'var(--accent)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
title={`AI Account: ${post.bot.name}`}
|
title={post.bot ? `AI Account: ${post.bot.name}` : `AI Account: ${post.author.displayName || post.author.handle}`}
|
||||||
>
|
>
|
||||||
<Bot size={12} />
|
<Bot size={12} />
|
||||||
AI Account
|
AI Account
|
||||||
@@ -663,8 +663,11 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
currentUser.id === post.author.id ||
|
currentUser.id === post.author.id ||
|
||||||
(post.bot && currentUser.id === post.bot.ownerId) ||
|
(post.bot && currentUser.id === post.bot.ownerId) ||
|
||||||
(parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
|
(parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
|
||||||
// Allow deleting own remote posts (where ID format differs but handle matches)
|
// Allow deleting own remote posts where handle might be username@node_domain
|
||||||
(post.author.id.startsWith('swarm:') && post.author.handle === currentUser.handle)
|
(post.author.id.startsWith('swarm:') && (
|
||||||
|
post.author.handle === currentUser.handle ||
|
||||||
|
post.author.handle === `${currentUser.handle}@${NODE_DOMAIN}`
|
||||||
|
))
|
||||||
)) && (
|
)) && (
|
||||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
|
|||||||
+25
-23
@@ -15,6 +15,7 @@ interface TimelineResult {
|
|||||||
|
|
||||||
interface TimelineOptions {
|
interface TimelineOptions {
|
||||||
includeNsfw?: boolean; // Whether to include NSFW content
|
includeNsfw?: boolean; // Whether to include NSFW content
|
||||||
|
cursor?: string; // Timestamp cursor for pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,19 +41,19 @@ async function fetchLinkPreview(url: string): Promise<{
|
|||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
|
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
|
||||||
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
|
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
|
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
|
||||||
|
|
||||||
const response = await fetch(previewUrl, {
|
const response = await fetch(previewUrl, {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
url: data.url || url,
|
url: data.url || url,
|
||||||
@@ -72,18 +73,18 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
|
|||||||
const enrichmentPromises = posts.map(async (post) => {
|
const enrichmentPromises = posts.map(async (post) => {
|
||||||
// Skip if already has link preview data
|
// Skip if already has link preview data
|
||||||
if (post.linkPreviewUrl) return post;
|
if (post.linkPreviewUrl) return post;
|
||||||
|
|
||||||
// Extract URL from content
|
// Extract URL from content
|
||||||
const url = extractFirstUrl(post.content);
|
const url = extractFirstUrl(post.content);
|
||||||
if (!url) return post;
|
if (!url) return post;
|
||||||
|
|
||||||
// Skip video URLs (handled by VideoEmbed component)
|
// Skip video URLs (handled by VideoEmbed component)
|
||||||
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
|
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
|
||||||
|
|
||||||
// Fetch preview
|
// Fetch preview
|
||||||
const preview = await fetchLinkPreview(url);
|
const preview = await fetchLinkPreview(url);
|
||||||
if (!preview) return post;
|
if (!preview) return post;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
linkPreviewUrl: preview.url,
|
linkPreviewUrl: preview.url,
|
||||||
@@ -92,7 +93,7 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
|
|||||||
linkPreviewImage: preview.image || undefined,
|
linkPreviewImage: preview.image || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(enrichmentPromises);
|
return Promise.all(enrichmentPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,8 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
|
|||||||
*/
|
*/
|
||||||
async function fetchNodeTimeline(
|
async function fetchNodeTimeline(
|
||||||
domain: string,
|
domain: string,
|
||||||
limit: number = 20
|
limit: number = 20,
|
||||||
|
cursor?: string
|
||||||
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// Determine protocol - use http for localhost, https for everything else
|
// Determine protocol - use http for localhost, https for everything else
|
||||||
@@ -113,7 +115,7 @@ async function fetchNodeTimeline(
|
|||||||
} else {
|
} else {
|
||||||
baseUrl = `https://${domain}`;
|
baseUrl = `https://${domain}`;
|
||||||
}
|
}
|
||||||
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}`;
|
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||||
@@ -148,14 +150,14 @@ export async function fetchSwarmTimeline(
|
|||||||
postsPerNode: number = 10,
|
postsPerNode: number = 10,
|
||||||
options: TimelineOptions = {}
|
options: TimelineOptions = {}
|
||||||
): Promise<TimelineResult> {
|
): Promise<TimelineResult> {
|
||||||
const { includeNsfw = false } = options;
|
const { includeNsfw = false, cursor } = options;
|
||||||
|
|
||||||
// Get active nodes to query
|
// Get active nodes to query
|
||||||
const nodes = await getActiveSwarmNodes(maxNodes);
|
const nodes = await getActiveSwarmNodes(maxNodes);
|
||||||
|
|
||||||
// Always include our own posts
|
// Always include our own posts
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
|
|
||||||
// Always query all nodes - we filter posts, not nodes
|
// Always query all nodes - we filter posts, not nodes
|
||||||
const nodesToQuery = [
|
const nodesToQuery = [
|
||||||
ourDomain,
|
ourDomain,
|
||||||
@@ -163,12 +165,12 @@ export async function fetchSwarmTimeline(
|
|||||||
].slice(0, maxNodes);
|
].slice(0, maxNodes);
|
||||||
|
|
||||||
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
|
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
|
||||||
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}`);
|
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
|
||||||
|
|
||||||
// Fetch from all nodes in parallel
|
// Fetch from all nodes in parallel
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
nodesToQuery.map(async (domain) => {
|
nodesToQuery.map(async (domain) => {
|
||||||
const result = await fetchNodeTimeline(domain, postsPerNode);
|
const result = await fetchNodeTimeline(domain, postsPerNode, cursor);
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
...result,
|
...result,
|
||||||
@@ -183,17 +185,17 @@ export async function fetchSwarmTimeline(
|
|||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
// Filter NSFW posts only if user doesn't want NSFW content
|
// Filter NSFW posts only if user doesn't want NSFW content
|
||||||
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
|
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
|
||||||
const filteredPosts = includeNsfw
|
const filteredPosts = includeNsfw
|
||||||
? result.posts
|
? result.posts
|
||||||
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
|
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
|
||||||
|
|
||||||
// Log filtering details for debugging
|
// Log filtering details for debugging
|
||||||
if (!includeNsfw && result.posts.length > 0) {
|
if (!includeNsfw && result.posts.length > 0) {
|
||||||
const nsfwPosts = result.posts.filter(p => p.isNsfw);
|
const nsfwPosts = result.posts.filter(p => p.isNsfw);
|
||||||
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
|
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
|
||||||
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`);
|
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`);
|
||||||
}
|
}
|
||||||
|
|
||||||
sources.push({
|
sources.push({
|
||||||
domain: result.domain,
|
domain: result.domain,
|
||||||
postCount: result.posts.length,
|
postCount: result.posts.length,
|
||||||
@@ -201,7 +203,7 @@ export async function fetchSwarmTimeline(
|
|||||||
isNsfw: result.nodeIsNsfw,
|
isNsfw: result.nodeIsNsfw,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
allPosts.push(...filteredPosts);
|
allPosts.push(...filteredPosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { fetchSwarmUserProfile } from './interactions';
|
||||||
|
|
||||||
|
export interface HydratedUser {
|
||||||
|
id: string; // The ID used in the list (usually handle or handle@domain)
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
isRemote: boolean;
|
||||||
|
nodeDomain?: string; // For remote users
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates a list of users with fresh profile data from Swarm nodes.
|
||||||
|
* Used for followers/following lists to ensure remote users have up-to-date info.
|
||||||
|
*
|
||||||
|
* @param users List of partial user objects
|
||||||
|
* @returns List of users with potentially updated profile data
|
||||||
|
*/
|
||||||
|
export async function hydrateSwarmUsers(
|
||||||
|
users: {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
isRemote: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
|
}[]
|
||||||
|
): Promise<HydratedUser[]> {
|
||||||
|
const needsHydration = users.filter(u => u.isRemote);
|
||||||
|
|
||||||
|
if (needsHydration.length === 0) {
|
||||||
|
return users.map(u => ({
|
||||||
|
...u,
|
||||||
|
displayName: u.displayName || u.handle.split('@')[0],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by domain to potentially batch (though fetchSwarmUserProfile is individual for now)
|
||||||
|
// We'll just run them concurrently with a limit
|
||||||
|
|
||||||
|
const hydratedMap = new Map<string, Partial<HydratedUser>>();
|
||||||
|
|
||||||
|
// Create a promise for each remote user
|
||||||
|
const promises = needsHydration.map(async (user) => {
|
||||||
|
try {
|
||||||
|
// Parse handle and domain
|
||||||
|
// Handle format for remote users in lists is usually "user@domain.com"
|
||||||
|
const parts = user.handle.split('@');
|
||||||
|
if (parts.length !== 2) return; // Should be user@domain
|
||||||
|
|
||||||
|
const handle = parts[0];
|
||||||
|
const domain = parts[1];
|
||||||
|
|
||||||
|
// Fetch profile
|
||||||
|
// We set a small timeout in fetchSwarmUserProfile (10s), but we might want shorter for lists?
|
||||||
|
// standard fetchSwarmUserProfile uses 10s. Let's stick with that for now or rely on the fact
|
||||||
|
// api routes have their own timeouts.
|
||||||
|
const response = await fetchSwarmUserProfile(handle, domain, 0); // 0 limit as we only want profile
|
||||||
|
|
||||||
|
if (response && response.profile) {
|
||||||
|
hydratedMap.set(user.id, {
|
||||||
|
displayName: response.profile.displayName,
|
||||||
|
avatarUrl: response.profile.avatarUrl,
|
||||||
|
bio: response.profile.bio,
|
||||||
|
isBot: response.profile.isBot,
|
||||||
|
nodeDomain: response.nodeDomain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Just ignore failures and keep original data
|
||||||
|
console.warn(`Failed to hydrate user ${user.handle}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run all (or batch if list is huge, but pagination limits to 20-50 usually)
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
return users.map(user => {
|
||||||
|
const freshdiv = hydratedMap.get(user.id);
|
||||||
|
if (freshdiv) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
...freshdiv,
|
||||||
|
// Ensure display name fallback
|
||||||
|
displayName: freshdiv.displayName || user.displayName || user.handle.split('@')[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
displayName: user.displayName || user.handle.split('@')[0],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user