Compare commits

...

1 Commits

Author SHA1 Message Date
giles
f085d4a8d0 Add search_actors to FederationService for paginated actor search
Fuzzy ILIKE search across remote actors and local profiles, with
WebFinger resolution for @user@domain queries. Supports page-based
pagination for infinite scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:18:43 +00:00
3 changed files with 93 additions and 0 deletions

View File

@@ -262,6 +262,10 @@ class FederationService(Protocol):
self, session: AsyncSession, acct: str,
) -> RemoteActorDTO | None: ...
async def search_actors(
self, session: AsyncSession, query: str, page: int = 1, limit: int = 20,
) -> tuple[list[RemoteActorDTO], int]: ...
# -- Following (outbound) -------------------------------------------------
async def send_follow(
self, session: AsyncSession, local_username: str, remote_actor_url: str,

View File

@@ -541,6 +541,92 @@ class SqlFederationService:
return await self._upsert_remote_actor(session, actor_url, data)
async def search_actors(
self, session: AsyncSession, query: str, page: int = 1, limit: int = 20,
) -> tuple[list[RemoteActorDTO], int]:
from sqlalchemy import or_
pattern = f"%{query}%"
offset = (page - 1) * limit
# WebFinger resolve for @user@domain queries (first page only)
webfinger_result: RemoteActorDTO | None = None
if page == 1 and "@" in query:
webfinger_result = await self.search_remote_actor(session, query)
# Search cached remote actors
remote_filter = or_(
RemoteActor.preferred_username.ilike(pattern),
RemoteActor.display_name.ilike(pattern),
RemoteActor.domain.ilike(pattern),
)
remote_total = (
await session.execute(
select(func.count(RemoteActor.id)).where(remote_filter)
)
).scalar() or 0
# Search local actor profiles
local_filter = or_(
ActorProfile.preferred_username.ilike(pattern),
ActorProfile.display_name.ilike(pattern),
)
local_total = (
await session.execute(
select(func.count(ActorProfile.id)).where(local_filter)
)
).scalar() or 0
total = remote_total + local_total
# Fetch remote actors page
remote_rows = (
await session.execute(
select(RemoteActor)
.where(remote_filter)
.order_by(RemoteActor.preferred_username)
.limit(limit)
.offset(offset)
)
).scalars().all()
results: list[RemoteActorDTO] = [_remote_actor_to_dto(r) for r in remote_rows]
# Fill remaining slots with local actors
remaining = limit - len(results)
local_offset = max(0, offset - remote_total)
if remaining > 0 and offset + len(results) >= remote_total:
domain = _domain()
local_rows = (
await session.execute(
select(ActorProfile)
.where(local_filter)
.order_by(ActorProfile.preferred_username)
.limit(remaining)
.offset(local_offset)
)
).scalars().all()
for lp in local_rows:
results.append(RemoteActorDTO(
id=0,
actor_url=f"https://{domain}/users/{lp.preferred_username}",
inbox_url=f"https://{domain}/users/{lp.preferred_username}/inbox",
preferred_username=lp.preferred_username,
domain=domain,
display_name=lp.display_name,
summary=lp.summary,
icon_url=None,
))
# Prepend WebFinger result (deduped)
if webfinger_result:
existing_urls = {r.actor_url for r in results}
if webfinger_result.actor_url not in existing_urls:
results.insert(0, webfinger_result)
total += 1
return results, total
# -- Following (outbound) -------------------------------------------------
async def send_follow(

View File

@@ -246,6 +246,9 @@ class StubFederationService:
async def search_remote_actor(self, session, acct):
return None
async def search_actors(self, session, query, page=1, limit=20):
return [], 0
async def send_follow(self, session, local_username, remote_actor_url):
raise RuntimeError("FederationService not available")