Fixed federation search
This commit is contained in:
@@ -1,6 +1,57 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, users, posts } from '@/db';
|
import { db, users, posts } from '@/db';
|
||||||
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
|
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
|
||||||
|
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||||
|
|
||||||
|
type SearchUser = {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
profileUrl?: string | null;
|
||||||
|
isRemote?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
||||||
|
let trimmed = query.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.startsWith('acct:')) {
|
||||||
|
trimmed = trimmed.slice(5);
|
||||||
|
}
|
||||||
|
const withoutPrefix = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
|
||||||
|
if (withoutPrefix.includes(' ')) return null;
|
||||||
|
const parts = withoutPrefix.split('@').filter(Boolean);
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [handle, domain] = parts;
|
||||||
|
if (!handle || !domain) return null;
|
||||||
|
if (!domain.includes('.') && !domain.includes(':')) return null;
|
||||||
|
return { handle: handle.toLowerCase(), domain: domain.toLowerCase() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRemoteUser = (
|
||||||
|
profile: Awaited<ReturnType<typeof resolveRemoteUser>>,
|
||||||
|
handle: string,
|
||||||
|
domain: string,
|
||||||
|
): SearchUser | null => {
|
||||||
|
if (!profile) return null;
|
||||||
|
const displayName = profile.name || profile.preferredUsername || null;
|
||||||
|
const username = profile.preferredUsername || handle;
|
||||||
|
if (!username) return null;
|
||||||
|
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
|
||||||
|
const iconUrl = typeof profile.icon === 'string' ? profile.icon : profile.icon?.url;
|
||||||
|
const profileUrl = typeof profile.url === 'string' ? profile.url : profile.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id || profileUrl || `remote:${fullHandle}`,
|
||||||
|
handle: fullHandle,
|
||||||
|
displayName,
|
||||||
|
avatarUrl: iconUrl ?? null,
|
||||||
|
bio: profile.summary ?? null,
|
||||||
|
profileUrl: profileUrl ?? null,
|
||||||
|
isRemote: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +74,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchPattern = `%${query}%`;
|
const searchPattern = `%${query}%`;
|
||||||
let searchUsers: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; bio: string | null }[] = [];
|
let searchUsers: SearchUser[] = [];
|
||||||
let searchPosts: typeof posts.$inferSelect[] = [];
|
let searchPosts: typeof posts.$inferSelect[] = [];
|
||||||
|
|
||||||
// Search users
|
// Search users
|
||||||
@@ -49,6 +100,18 @@ export async function GET(request: Request) {
|
|||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Federated user lookup (exact handle@domain queries)
|
||||||
|
if ((type === 'all' || type === 'users') && searchUsers.length < limit) {
|
||||||
|
const parsedRemote = parseRemoteHandleQuery(query);
|
||||||
|
if (parsedRemote) {
|
||||||
|
const remoteProfile = await resolveRemoteUser(parsedRemote.handle, parsedRemote.domain);
|
||||||
|
const remoteUser = buildRemoteUser(remoteProfile, parsedRemote.handle, parsedRemote.domain);
|
||||||
|
if (remoteUser && !searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
||||||
|
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const moderatedUsers = await db.select({ id: users.id })
|
const moderatedUsers = await db.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, type ElementType } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
|
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ interface User {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
|
profileUrl?: string | null;
|
||||||
|
isRemote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
@@ -106,8 +108,14 @@ function PostCard({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const isRemote = Boolean(user.isRemote && user.profileUrl);
|
||||||
|
const Wrapper: ElementType = isRemote ? 'a' : Link;
|
||||||
|
const wrapperProps = isRemote
|
||||||
|
? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: { href: `/${user.handle}` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.handle}`} className="user-card">
|
<Wrapper {...wrapperProps} className="user-card">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName} />
|
<img src={user.avatarUrl} alt={user.displayName} />
|
||||||
@@ -120,7 +128,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
<div className="user-card-handle">@{user.handle}</div>
|
<div className="user-card-handle">@{user.handle}</div>
|
||||||
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -136,13 +136,7 @@ export default function Home() {
|
|||||||
<div className="feed-meta card">
|
<div className="feed-meta card">
|
||||||
<div className="feed-meta-title">Curated feed</div>
|
<div className="feed-meta-title">Curated feed</div>
|
||||||
<div className="feed-meta-body">
|
<div className="feed-meta-body">
|
||||||
We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
|
This feed highlights fresh posts and active discussions, with a boost for people you follow. It is designed to surface what matters without hiding your own activity.
|
||||||
</div>
|
|
||||||
<div className="feed-meta-weights">
|
|
||||||
Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
|
|
||||||
</div>
|
|
||||||
<div className="feed-meta-foot">
|
|
||||||
Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+12
-4
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, type ElementType } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ interface User {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
|
profileUrl?: string | null;
|
||||||
|
isRemote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
@@ -73,9 +75,15 @@ const FlagIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const isRemote = Boolean(user.isRemote && user.profileUrl);
|
||||||
|
const Wrapper: ElementType = isRemote ? 'a' : Link;
|
||||||
|
const wrapperProps = isRemote
|
||||||
|
? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: { href: `/@${user.handle}` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Wrapper
|
||||||
href={`/@${user.handle}`}
|
{...wrapperProps}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -109,7 +117,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
import { fetchWebFinger, getActorUrlFromWebFinger } from './webfinger';
|
||||||
|
|
||||||
|
export interface ActivityPubProfile {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
preferredUsername: string;
|
||||||
|
name?: string;
|
||||||
|
summary?: string;
|
||||||
|
url?: string;
|
||||||
|
icon?: {
|
||||||
|
url: string;
|
||||||
|
} | string;
|
||||||
|
image?: {
|
||||||
|
url: string;
|
||||||
|
} | string;
|
||||||
|
publicKey?: {
|
||||||
|
id: string;
|
||||||
|
owner: string;
|
||||||
|
publicKeyPem: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a remote ActivityPub actor
|
||||||
|
*/
|
||||||
|
export async function fetchRemoteActor(url: string): Promise<ActivityPubProfile | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||||
|
'User-Agent': 'Synapsis/1.0 (+https://synapsis.social)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Failed to fetch actor: ${res.status} ${res.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!data.id || !data.type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as ActivityPubProfile;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching remote actor:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a remote user via WebFinger and fetch their profile
|
||||||
|
* @param handle The username (without domain)
|
||||||
|
* @param domain The domain name
|
||||||
|
*/
|
||||||
|
export async function resolveRemoteUser(handle: string, domain: string): Promise<ActivityPubProfile | null> {
|
||||||
|
// 1. WebFinger lookup
|
||||||
|
const webfinger = await fetchWebFinger(handle, domain);
|
||||||
|
if (!webfinger) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get Actor URL
|
||||||
|
const actorUrl = getActorUrlFromWebFinger(webfinger);
|
||||||
|
if (!actorUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch Actor Profile
|
||||||
|
return await fetchRemoteActor(actorUrl);
|
||||||
|
}
|
||||||
@@ -106,8 +106,11 @@ export async function fetchWebFinger(
|
|||||||
* Get the ActivityPub actor URL from a WebFinger response
|
* Get the ActivityPub actor URL from a WebFinger response
|
||||||
*/
|
*/
|
||||||
export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
|
export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
|
||||||
const selfLink = webfinger.links.find(
|
const selfLink = webfinger.links.find((link) => {
|
||||||
(link) => link.rel === 'self' && link.type === 'application/activity+json'
|
if (link.rel !== 'self' || !link.href) return false;
|
||||||
);
|
if (!link.type) return true;
|
||||||
|
const type = link.type.toLowerCase();
|
||||||
|
return type.includes('activity+json') || type.includes('activitystreams') || type.includes('application/ld+json');
|
||||||
|
});
|
||||||
return selfLink?.href ?? null;
|
return selfLink?.href ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user