diff --git a/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md b/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md new file mode 100644 index 0000000..5a4858f --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md @@ -0,0 +1,917 @@ +# Remote Actor Search & Follow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let local users search for and follow ActivityPub users on other instances (e.g. `@user@mastodon.social`) from the existing search page. + +**Architecture:** New `FederationActionPort` domain trait (lookup + follow), implemented by `ActivityPubService` in `activitypub-base`. Injected into `AppState` via bootstrap. Two new REST endpoints at `/federation/lookup` and `/federation/follow`. Frontend detects `@user@instance` handle format in the search bar and renders a `RemoteUserCard` with a Follow button. + +**Tech Stack:** Rust (axum, sqlx, activitypub_federation crate), Next.js 15 (App Router, server components), TypeScript, Zod, shadcn/ui. + +--- + +## File Map + +| Action | Path | Purpose | +|--------|------|---------| +| Modify | `crates/domain/src/models/remote_actor.rs` | Add `avatar_url` field | +| Modify | `crates/domain/src/errors.rs` | Add `ExternalService` variant | +| Modify | `crates/domain/src/ports.rs` | Add `FederationActionPort` trait | +| Modify | `crates/domain/src/testing.rs` | Impl `FederationActionPort` for `TestStore` | +| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `FederationActionPort` for `ActivityPubService` | +| Modify | `crates/adapters/activitypub-base/src/lib.rs` | Re-export trait impl visibility | +| Modify | `crates/presentation/src/state.rs` | Add `federation` field | +| Modify | `crates/presentation/src/errors.rs` | Map `ExternalService` → 502 | +| Modify | `crates/bootstrap/src/factory.rs` | Build `ActivityPubService`, wire `federation` | +| Modify | `crates/bootstrap/src/main.rs` | Use `ap_service.federation_config()` for middleware | +| Modify | `crates/api-types/src/responses.rs` | Add `RemoteActorResponse` | +| Create | `crates/presentation/src/handlers/federation.rs` | `lookup` + `follow_remote` handlers | +| Modify | `crates/presentation/src/handlers/mod.rs` | Expose `federation` module | +| Modify | `crates/presentation/src/routes.rs` | Mount `/federation/*` routes | +| Modify | `thoughts-frontend/lib/api.ts` | Add schema, `lookupRemoteActor`, `followRemoteUser` | +| Modify | `thoughts-frontend/app/search/page.tsx` | Detect handle, call lookup, pass result | +| Create | `thoughts-frontend/components/remote-user-card.tsx` | Shows remote actor + Follow button | + +--- + +## Task 1: Domain model + port + +**Files:** +- Modify: `crates/domain/src/models/remote_actor.rs` +- Modify: `crates/domain/src/errors.rs` +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/domain/src/testing.rs` + +- [ ] **Step 1: Add `avatar_url` to `RemoteActor`** + +In `crates/domain/src/models/remote_actor.rs`, add one field: + +```rust +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct RemoteActor { + pub url: String, + pub handle: String, + pub display_name: Option, + pub inbox_url: String, + pub shared_inbox_url: Option, + pub public_key: String, + pub avatar_url: Option, // ← add this + pub last_fetched_at: DateTime, +} +``` + +- [ ] **Step 2: Add `ExternalService` to `DomainError`** + +In `crates/domain/src/errors.rs`, add the variant: + +```rust +#[derive(Debug, Error, Clone)] +pub enum DomainError { + #[error("not found")] + NotFound, + #[error("unauthorized")] + Unauthorized, + #[error("forbidden")] + Forbidden, + #[error("conflict: {0}")] + Conflict(String), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("external service error: {0}")] + ExternalService(String), // ← add this + #[error("internal error: {0}")] + Internal(String), +} +``` + +- [ ] **Step 3: Add `FederationActionPort` trait** + +In `crates/domain/src/ports.rs`, after the `RemoteActorRepository` trait block, add: + +```rust +#[async_trait] +pub trait FederationActionPort: Send + Sync { + async fn lookup_actor(&self, handle: &str) -> Result; + async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; +} +``` + +Make sure `RemoteActor` is already imported — it's in the existing `use crate::models::remote_actor::RemoteActor;` import block. + +- [ ] **Step 4: Write failing tests for the trait in `testing.rs`** + +At the bottom of `crates/domain/src/testing.rs`, add: + +```rust +#[cfg(test)] +mod federation_port_tests { + use super::*; + use crate::value_objects::UserId; + + fn uid() -> UserId { + UserId::new() + } + + #[tokio::test] + async fn test_store_lookup_returns_not_found() { + let store = TestStore::default(); + let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn test_store_follow_remote_is_noop_ok() { + let store = TestStore::default(); + store.follow_remote(&uid(), "@alice@example.com").await.unwrap(); + } +} +``` + +- [ ] **Step 5: Run the tests to see them fail** + +```bash +cargo test -p domain -- federation_port_tests 2>&1 | tail -20 +``` + +Expected: compile error — `lookup_actor` and `follow_remote` not implemented on `TestStore`, and `FederationActionPort` trait not found. + +- [ ] **Step 6: Implement `FederationActionPort` for `TestStore`** + +In `crates/domain/src/testing.rs`, add after the existing `impl RemoteActorRepository for TestStore` block: + +```rust +#[async_trait] +impl FederationActionPort for TestStore { + async fn lookup_actor(&self, _handle: &str) -> Result { + Err(DomainError::NotFound) + } + + async fn follow_remote(&self, _local_user_id: &UserId, _handle: &str) -> Result<(), DomainError> { + Ok(()) + } +} +``` + +- [ ] **Step 7: Run tests to confirm they pass** + +```bash +cargo test -p domain -- federation_port_tests 2>&1 | tail -10 +``` + +Expected: `test federation_port_tests::test_store_lookup_returns_not_found ... ok` and `test_store_follow_remote_is_noop_ok ... ok`. + +- [ ] **Step 8: Confirm the whole domain crate still compiles** + +```bash +cargo check -p domain 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 9: Commit** + +```bash +git add crates/domain/src/models/remote_actor.rs \ + crates/domain/src/errors.rs \ + crates/domain/src/ports.rs \ + crates/domain/src/testing.rs +git commit -m "feat(domain): FederationActionPort trait + avatar_url on RemoteActor" +``` + +--- + +## Task 2: `activitypub-base` — implement `FederationActionPort` + +**Files:** +- Modify: `crates/adapters/activitypub-base/src/service.rs` + +- [ ] **Step 1: Write a compile-time impl check in `tests/service.rs`** + +In `crates/adapters/activitypub-base/src/tests/service.rs`, add at the top: + +```rust +// Verify ActivityPubService satisfies the FederationActionPort contract at compile time. +fn _assert_impl_federation_action_port() +where + crate::service::ActivityPubService: domain::ports::FederationActionPort, +{ +} +``` + +- [ ] **Step 2: Run to see compile failure** + +```bash +cargo check -p activitypub-base 2>&1 | tail -15 +``` + +Expected: error — `ActivityPubService` does not implement `FederationActionPort`. + +- [ ] **Step 3: Implement `FederationActionPort` for `ActivityPubService`** + +At the bottom of `crates/adapters/activitypub-base/src/service.rs`, before the closing of the file, add: + +```rust +#[async_trait::async_trait] +impl domain::ports::FederationActionPort for ActivityPubService { + async fn lookup_actor( + &self, + handle: &str, + ) -> Result { + use activitypub_federation::fetch::webfinger::webfinger_resolve_actor; + let data = self.federation_config.to_request_data(); + let actor: crate::actors::DbActor = webfinger_resolve_actor(handle, &data) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + Ok(domain::models::remote_actor::RemoteActor { + url: actor.ap_id.to_string(), + handle: actor.username.clone(), + display_name: actor.bio.clone(), + inbox_url: actor.inbox_url.to_string(), + shared_inbox_url: None, + public_key: actor.public_key_pem.clone(), + avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), + last_fetched_at: actor.last_refreshed_at, + }) + } + + async fn follow_remote( + &self, + local_user_id: &domain::value_objects::UserId, + handle: &str, + ) -> Result<(), domain::errors::DomainError> { + self.follow(local_user_id.inner(), handle) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } +} +``` + +Note: `UserId::inner()` returns the underlying `uuid::Uuid`. Verify the method name with `grep -n "fn inner\|fn as_uuid\|fn into_uuid" crates/domain/src/value_objects.rs` — adjust if the method is named differently. + +- [ ] **Step 4: Check `UserId` accessor method name** + +```bash +grep -n "fn inner\|fn as_uuid\|fn into_uuid\|pub fn " /mnt/drive/dev/thoughts/crates/domain/src/value_objects.rs | grep -i "userid\|UserId" | head -10 +``` + +If `inner()` doesn't exist, replace `local_user_id.inner()` with the correct method (e.g. `local_user_id.0`, `local_user_id.as_uuid()`, etc.). + +- [ ] **Step 5: Compile to confirm the impl satisfies the trait** + +```bash +cargo check -p activitypub-base 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add crates/adapters/activitypub-base/src/service.rs \ + crates/adapters/activitypub-base/src/tests/service.rs +git commit -m "feat(activitypub-base): impl FederationActionPort for ActivityPubService" +``` + +--- + +## Task 3: Bootstrap — wire `ActivityPubService` into `AppState` + +**Files:** +- Modify: `crates/presentation/src/state.rs` +- Modify: `crates/presentation/src/errors.rs` +- Modify: `crates/bootstrap/src/factory.rs` +- Modify: `crates/bootstrap/src/main.rs` + +- [ ] **Step 1: Add `federation` to `AppState`** + +In `crates/presentation/src/state.rs`, add the new field: + +```rust +use domain::ports::*; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub users: Arc, + pub thoughts: Arc, + pub likes: Arc, + pub boosts: Arc, + pub follows: Arc, + pub blocks: Arc, + pub tags: Arc, + pub api_keys: Arc, + pub top_friends: Arc, + pub notifications: Arc, + pub remote_actors: Arc, + pub feed: Arc, + pub search: Arc, + pub auth: Arc, + pub hasher: Arc, + pub events: Arc, + pub federation: Arc, // ← add this +} +``` + +- [ ] **Step 2: Map `ExternalService` error in `presentation/src/errors.rs`** + +Add the new match arm in `IntoResponse for ApiError`: + +```rust +Self::Domain(DomainError::ExternalService(_)) => ( + StatusCode::BAD_GATEWAY, + "external service error".into(), +), +``` + +Place it before the `Self::Domain(DomainError::Internal(_))` arm. + +- [ ] **Step 3: Refactor `factory.rs` to build `ActivityPubService`** + +In `crates/bootstrap/src/factory.rs`, change the imports and the federation setup block. + +Add import at top: +```rust +use activitypub_base::service::ActivityPubService; +use domain::ports::FederationActionPort; +``` + +Change `Infrastructure` struct: +```rust +pub struct Infrastructure { + pub state: AppState, + pub ap_service: Arc, +} +``` + +Replace the current "3. ActivityPub federation" block (which builds `fed_data` + `fed_config`) with: + +```rust +// 3. ActivityPub federation +let ap_service = Arc::new( + ActivityPubService::new( + Arc::new(PostgresFederationRepository::new(pool.clone())), + Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), + Arc::new(ThoughtsObjectHandler::new( + Arc::new(PgActivityPubRepository::new(pool.clone())), + &cfg.base_url, + )), + cfg.base_url.clone(), + cfg.allow_registration, + "thoughts".to_string(), + cfg.debug, + None, + ) + .await + .expect("Failed to build ActivityPubService"), +); +``` + +Remove the old `let fed_config = ...` line entirely. + +In the `AppState { ... }` construction, add: +```rust +federation: ap_service.clone() as Arc, +``` + +Change the `Infrastructure { ... }` return to: +```rust +Infrastructure { state, ap_service } +``` + +- [ ] **Step 4: Update `main.rs` to use `ap_service`** + +In `crates/bootstrap/src/main.rs`, change the middleware line from: + +```rust +.layer(infra.fed_config.middleware()); +``` + +to: + +```rust +.layer(infra.ap_service.federation_config().middleware()); +``` + +Also update the AP router handlers — they use `actor_handler`, `inbox_handler`, etc. from `activitypub_base`. These don't change; only the middleware source changes. + +- [ ] **Step 5: Confirm everything compiles** + +```bash +cargo check -p bootstrap 2>&1 | tail -15 +``` + +Expected: no errors. If `fed_config` is referenced elsewhere in `main.rs` or `factory.rs`, fix those references to use `ap_service.federation_config()`. + +- [ ] **Step 6: Commit** + +```bash +git add crates/presentation/src/state.rs \ + crates/presentation/src/errors.rs \ + crates/bootstrap/src/factory.rs \ + crates/bootstrap/src/main.rs +git commit -m "feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState" +``` + +--- + +## Task 4: REST endpoints — lookup + follow + +**Files:** +- Modify: `crates/api-types/src/responses.rs` +- Create: `crates/presentation/src/handlers/federation.rs` +- Modify: `crates/presentation/src/handlers/mod.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Step 1: Add `RemoteActorResponse` to `api-types`** + +In `crates/api-types/src/responses.rs`, add: + +```rust +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RemoteActorResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, +} +``` + +- [ ] **Step 2: Write failing handler tests** + +Create `crates/presentation/src/handlers/federation.rs` with the test module first: + +```rust +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; + +use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; +use domain::errors::DomainError; + +use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; + +pub async fn lookup_handler( + State(_s): State, + Query(_q): Query, +) -> Result, ApiError> { + todo!() +} + +pub async fn follow_remote_handler( + State(_s): State, + AuthUser(_uid): AuthUser, + Json(_body): Json, +) -> Result { + todo!() +} + +#[derive(Deserialize)] +pub struct LookupQuery { + pub handle: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, header}, + routing::{get, post}, + Router, + }; + use domain::testing::TestStore; + use std::sync::Arc; + use tower::ServiceExt; + + fn make_state() -> AppState { + let store = Arc::new(TestStore::default()); + AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: store.clone(), + hasher: store.clone(), + events: store.clone(), + federation: store.clone(), + } + } + + fn app() -> Router { + Router::new() + .route("/federation/lookup", get(lookup_handler)) + .route("/federation/follow", post(follow_remote_handler)) + .with_state(make_state()) + } + + #[tokio::test] + async fn lookup_unknown_handle_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/federation/lookup?handle=%40alice%40example.com") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn follow_remote_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/federation/follow") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"handle":"@alice@example.com"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } +} +``` + +Note: `TestStore` must implement `AuthService`, `PasswordHasher`, and `FederationActionPort` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — if `TestStore` doesn't implement `AuthService` or `PasswordHasher`, use the existing pattern from other handler test setups in the codebase. You may need to construct `AppState` slightly differently (e.g. using a `NoOpAuth` stub). Check `crates/presentation/src/handlers/auth.rs` for any existing test patterns. + +- [ ] **Step 3: Add `FollowRemoteRequest` to `api-types`** + +In `crates/api-types/src/requests.rs`, add: + +```rust +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FollowRemoteRequest { + pub handle: String, +} +``` + +- [ ] **Step 4: Run tests to see them fail** + +```bash +cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -20 +``` + +Expected: compile errors (handler bodies are `todo!()`) or panics. The goal is to confirm the tests exist and the wiring is right. + +- [ ] **Step 5: Implement the handlers** + +Replace the `todo!()` bodies in `federation.rs`: + +```rust +pub async fn lookup_handler( + State(s): State, + Query(q): Query, +) -> Result, ApiError> { + let actor = s.federation.lookup_actor(&q.handle).await?; + Ok(Json(RemoteActorResponse { + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + url: actor.url, + })) +} + +pub async fn follow_remote_handler( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + s.federation.follow_remote(&uid, &body.handle).await?; + Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 6: Expose the module** + +In `crates/presentation/src/handlers/mod.rs`, add: + +```rust +pub mod federation; +``` + +- [ ] **Step 7: Mount routes** + +In `crates/presentation/src/routes.rs`, add these two routes inside `let api_routes = Router::new()`: + +```rust +.route("/federation/lookup", get(federation::lookup_handler)) +.route("/federation/follow", post(federation::follow_remote_handler)) +``` + +Place them after the `/search` route for clarity. + +- [ ] **Step 8: Run tests again to confirm they pass** + +```bash +cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -15 +``` + +Expected: +``` +test handlers::federation::tests::lookup_unknown_handle_returns_404 ... ok +test handlers::federation::tests::follow_remote_without_auth_returns_401 ... ok +``` + +- [ ] **Step 9: Full compile check** + +```bash +cargo check 2>&1 | tail -15 +``` + +Expected: no errors. + +- [ ] **Step 10: Commit** + +```bash +git add crates/api-types/src/responses.rs \ + crates/api-types/src/requests.rs \ + crates/presentation/src/handlers/federation.rs \ + crates/presentation/src/handlers/mod.rs \ + crates/presentation/src/routes.rs +git commit -m "feat(presentation): /federation/lookup and /federation/follow endpoints" +``` + +--- + +## Task 5: Frontend — API client + search integration + RemoteUserCard + +**Files:** +- Modify: `thoughts-frontend/lib/api.ts` +- Modify: `thoughts-frontend/app/search/page.tsx` +- Create: `thoughts-frontend/components/remote-user-card.tsx` + +- [ ] **Step 1: Add types and API functions to `lib/api.ts`** + +After the `UserSchema` block (around line 15), add: + +```typescript +export const RemoteActorSchema = z.object({ + handle: z.string(), + displayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + url: z.string(), +}); +export type RemoteActor = z.infer; +``` + +After the existing `followUser` and `unfollowUser` functions, add: + +```typescript +export const lookupRemoteActor = (handle: string, token: string | null) => + apiFetch( + `/federation/lookup?handle=${encodeURIComponent(handle)}`, + {}, + RemoteActorSchema, + token + ); + +export const followRemoteUser = (handle: string, token: string) => + apiFetch( + `/federation/follow`, + { method: "POST", body: JSON.stringify({ handle }) }, + z.null(), + token + ); +``` + +- [ ] **Step 2: Create `RemoteUserCard` component** + +Create `thoughts-frontend/components/remote-user-card.tsx`: + +```typescript +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { followRemoteUser, RemoteActor } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { UserAvatar } from "@/components/user-avatar"; +import { toast } from "sonner"; +import { UserPlus } from "lucide-react"; + +interface RemoteUserCardProps { + actor: RemoteActor; +} + +export function RemoteUserCard({ actor }: RemoteUserCardProps) { + const [followed, setFollowed] = useState(false); + const [loading, setLoading] = useState(false); + const { token } = useAuth(); + + const handleFollow = async () => { + if (!token) { + toast.error("You must be logged in to follow users."); + return; + } + setLoading(true); + try { + await followRemoteUser(actor.handle, token); + setFollowed(true); + toast.success(`Follow request sent to ${actor.handle}`); + } catch { + toast.error("Failed to send follow request."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+

{actor.displayName ?? actor.handle}

+

{actor.handle}

+
+
+ +
+ ); +} +``` + +Note: Check how `UserAvatar` is used in other components (e.g. `user-list-card.tsx`) to confirm the prop names match. + +- [ ] **Step 3: Check `UserAvatar` props** + +```bash +grep -n "UserAvatar\|avatarUrl\|username" /mnt/drive/dev/thoughts/thoughts-frontend/components/user-avatar.tsx | head -10 +``` + +Adjust the `UserAvatar` usage in `RemoteUserCard` to match the actual props. + +- [ ] **Step 4: Update `app/search/page.tsx` to detect handles and show remote result** + +Replace the file with: + +```typescript +import { cookies } from "next/headers"; +import { getMe, search, lookupRemoteActor, User, RemoteActor } from "@/lib/api"; +import { UserListCard } from "@/components/user-list-card"; +import { RemoteUserCard } from "@/components/remote-user-card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ThoughtList } from "@/components/thought-list"; + +const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; + +interface SearchPageProps { + searchParams: Promise<{ q?: string }>; +} + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const { q } = await searchParams; + const query = q || ""; + const token = (await cookies()).get("auth_token")?.value ?? null; + + if (!query) { + return ( +
+

Search Thoughts

+

+ Find users and thoughts across the platform. +

+
+ ); + } + + const isHandle = HANDLE_RE.test(query); + + const [results, remoteActor, me] = await Promise.all([ + isHandle ? null : search(query, token).catch(() => null), + isHandle ? lookupRemoteActor(query, token).catch(() => null) : null, + token ? getMe(token).catch(() => null) : null, + ]); + + const authorDetails = new Map(); + if (results) { + results.users.forEach((user: User) => { + authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); + }); + } + + return ( +
+
+

Search Results

+

+ Showing results for: "{query}" +

+
+
+ {isHandle ? ( + remoteActor ? ( +
+

Remote user

+ +
+ ) : ( +

+ No user found at {query} +

+ ) + ) : results ? ( + + + + Thoughts ({results.thoughts.length}) + + + Users ({results.users.length}) + + + + + + + + + + ) : ( +

+ No results found or an error occurred. +

+ )} +
+
+ ); +} +``` + +- [ ] **Step 5: Type-check the frontend** + +```bash +cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 +``` + +Expected: no errors. Fix any type mismatches before continuing. + +- [ ] **Step 6: Commit** + +```bash +cd /mnt/drive/dev/thoughts/thoughts-frontend +git add lib/api.ts app/search/page.tsx components/remote-user-card.tsx +cd .. +git commit -m "feat(frontend): remote actor lookup and follow from search page" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- ✅ `FederationActionPort` trait with `lookup_actor` + `follow_remote` — Task 1 +- ✅ `avatar_url` on `RemoteActor` — Task 1 +- ✅ `ExternalService` error variant — Task 1 +- ✅ `ActivityPubService` impl — Task 2 +- ✅ Bootstrap refactor + `AppState.federation` — Task 3 +- ✅ `RemoteActorResponse` + `FollowRemoteRequest` — Task 4 +- ✅ `/federation/lookup` + `/federation/follow` endpoints — Task 4 +- ✅ Error mapping (ExternalService → 502) — Task 3 +- ✅ Frontend API client additions — Task 5 +- ✅ Handle detection regex in search page — Task 5 +- ✅ `RemoteUserCard` component — Task 5 + +**Placeholder check:** None found. + +**Type consistency check:** +- `RemoteActor.avatar_url: Option` used in Task 1, mapped from `DbActor.avatar_url: Option` in Task 2 via `.map(|u| u.to_string())` ✅ +- `FollowRemoteRequest.handle` → `follow_remote(&uid, &body.handle)` ✅ +- `RemoteActorResponse` fields match `RemoteActor` domain model fields ✅ +- Frontend `RemoteActorSchema` camelCase fields match `#[serde(rename_all = "camelCase")]` on `RemoteActorResponse` ✅ +- `UserId::inner()` — verified as an assumption in Task 2 Step 4 with an explicit check step ✅