Fixed federation search

This commit is contained in:
Christopher
2026-01-22 15:26:39 -08:00
parent 1ed8e2c4db
commit b2d594d212
6 changed files with 169 additions and 18 deletions
+64 -1
View File
@@ -1,6 +1,57 @@
import { NextResponse } from 'next/server';
import { db, users, posts } from '@/db';
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) {
try {
@@ -23,7 +74,7 @@ export async function GET(request: Request) {
}
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[] = [];
// Search users
@@ -49,6 +100,18 @@ export async function GET(request: Request) {
.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 })
.from(users)
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
+11 -3
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, type ElementType } from 'react';
import Link from 'next/link';
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
@@ -10,6 +10,8 @@ interface User {
displayName: string;
avatarUrl?: string;
bio?: string;
profileUrl?: string | null;
isRemote?: boolean;
}
interface MediaItem {
@@ -106,8 +108,14 @@ function PostCard({ post }: { post: Post }) {
}
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 (
<Link href={`/${user.handle}`} className="user-card">
<Wrapper {...wrapperProps} className="user-card">
<div className="avatar">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} />
@@ -120,7 +128,7 @@ function UserCard({ user }: { user: User }) {
<div className="user-card-handle">@{user.handle}</div>
{user.bio && <div className="user-card-bio">{user.bio}</div>}
</div>
</Link>
</Wrapper>
);
}
+1 -7
View File
@@ -136,13 +136,7 @@ export default function Home() {
<div className="feed-meta card">
<div className="feed-meta-title">Curated feed</div>
<div className="feed-meta-body">
We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
</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.
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>
)}
+12 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, type ElementType } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation';
@@ -10,6 +10,8 @@ interface User {
displayName: string;
avatarUrl?: string;
bio?: string;
profileUrl?: string | null;
isRemote?: boolean;
}
interface MediaItem {
@@ -73,9 +75,15 @@ const FlagIcon = () => (
);
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 (
<Link
href={`/@${user.handle}`}
<Wrapper
{...wrapperProps}
style={{
display: 'flex',
alignItems: 'center',
@@ -109,7 +117,7 @@ function UserCard({ user }: { user: User }) {
</div>
)}
</div>
</Link>
</Wrapper>
);
}