feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
Showing only changes of commit 8eb59bfac6 - Show all commits

View File

@@ -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<String>,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub public_key: String,
pub avatar_url: Option<String>, // ← add this
pub last_fetched_at: DateTime<Utc>,
}
```
- [ ] **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<RemoteActor, DomainError>;
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<RemoteActor, DomainError> {
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<domain::models::remote_actor::RemoteActor, domain::errors::DomainError> {
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<dyn UserRepository>,
pub thoughts: Arc<dyn ThoughtRepository>,
pub likes: Arc<dyn LikeRepository>,
pub boosts: Arc<dyn BoostRepository>,
pub follows: Arc<dyn FollowRepository>,
pub blocks: Arc<dyn BlockRepository>,
pub tags: Arc<dyn TagRepository>,
pub api_keys: Arc<dyn ApiKeyRepository>,
pub top_friends: Arc<dyn TopFriendRepository>,
pub notifications: Arc<dyn NotificationRepository>,
pub remote_actors: Arc<dyn RemoteActorRepository>,
pub feed: Arc<dyn FeedRepository>,
pub search: Arc<dyn SearchPort>,
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
pub federation: Arc<dyn FederationActionPort>, // ← 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<ActivityPubService>,
}
```
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<dyn FederationActionPort>,
```
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<String>,
pub avatar_url: Option<String>,
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<AppState>,
Query(_q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
todo!()
}
pub async fn follow_remote_handler(
State(_s): State<AppState>,
AuthUser(_uid): AuthUser,
Json(_body): Json<FollowRemoteRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, 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<AppState>,
AuthUser(uid): AuthUser,
Json(body): Json<FollowRemoteRequest>,
) -> Result<StatusCode, ApiError> {
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<typeof RemoteActorSchema>;
```
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 (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<UserAvatar
username={actor.handle}
avatarUrl={actor.avatarUrl}
size="md"
/>
<div>
<p className="font-medium">{actor.displayName ?? actor.handle}</p>
<p className="text-sm text-muted-foreground">{actor.handle}</p>
</div>
</div>
<Button
onClick={handleFollow}
disabled={loading || followed}
variant={followed ? "secondary" : "default"}
size="sm"
>
<UserPlus className="mr-2 h-4 w-4" />
{followed ? "Requested" : "Follow"}
</Button>
</div>
);
}
```
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 (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
<p className="text-muted-foreground">
Find users and thoughts across the platform.
</p>
</div>
);
}
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<string, { avatarUrl?: string | null }>();
if (results) {
results.users.forEach((user: User) => {
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
});
}
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground">
Showing results for: &quot;{query}&quot;
</p>
</header>
<main>
{isHandle ? (
remoteActor ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Remote user</h2>
<RemoteUserCard actor={remoteActor} />
</div>
) : (
<p className="text-center text-muted-foreground pt-8">
No user found at {query}
</p>
)
) : results ? (
<Tabs defaultValue="thoughts" className="w-full">
<TabsList>
<TabsTrigger value="thoughts">
Thoughts ({results.thoughts.length})
</TabsTrigger>
<TabsTrigger value="users">
Users ({results.users.length})
</TabsTrigger>
</TabsList>
<TabsContent value="thoughts">
<ThoughtList
thoughts={results.thoughts}
authorDetails={authorDetails}
currentUser={me}
/>
</TabsContent>
<TabsContent value="users">
<UserListCard users={results.users} />
</TabsContent>
</Tabs>
) : (
<p className="text-center text-muted-foreground pt-8">
No results found or an error occurred.
</p>
)}
</main>
</div>
);
}
```
- [ ] **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<String>` used in Task 1, mapped from `DbActor.avatar_url: Option<Url>` 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 ✅