28 KiB
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_urltoRemoteActor
In crates/domain/src/models/remote_actor.rs, add one field:
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
ExternalServicetoDomainError
In crates/domain/src/errors.rs, add the variant:
#[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
FederationActionPorttrait
In crates/domain/src/ports.rs, after the RemoteActorRepository trait block, add:
#[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:
#[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
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
FederationActionPortforTestStore
In crates/domain/src/testing.rs, add after the existing impl RemoteActorRepository for TestStore block:
#[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
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
cargo check -p domain 2>&1 | tail -10
Expected: no errors.
- Step 9: Commit
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:
// 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
cargo check -p activitypub-base 2>&1 | tail -15
Expected: error — ActivityPubService does not implement FederationActionPort.
- Step 3: Implement
FederationActionPortforActivityPubService
At the bottom of crates/adapters/activitypub-base/src/service.rs, before the closing of the file, add:
#[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
UserIdaccessor method name
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
cargo check -p activitypub-base 2>&1 | tail -10
Expected: no errors.
- Step 6: Commit
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
federationtoAppState
In crates/presentation/src/state.rs, add the new field:
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
ExternalServiceerror inpresentation/src/errors.rs
Add the new match arm in IntoResponse for ApiError:
Self::Domain(DomainError::ExternalService(_)) => (
StatusCode::BAD_GATEWAY,
"external service error".into(),
),
Place it before the Self::Domain(DomainError::Internal(_)) arm.
- Step 3: Refactor
factory.rsto buildActivityPubService
In crates/bootstrap/src/factory.rs, change the imports and the federation setup block.
Add import at top:
use activitypub_base::service::ActivityPubService;
use domain::ports::FederationActionPort;
Change Infrastructure struct:
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:
// 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:
federation: ap_service.clone() as Arc<dyn FederationActionPort>,
Change the Infrastructure { ... } return to:
Infrastructure { state, ap_service }
- Step 4: Update
main.rsto useap_service
In crates/bootstrap/src/main.rs, change the middleware line from:
.layer(infra.fed_config.middleware());
to:
.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
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
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
RemoteActorResponsetoapi-types
In crates/api-types/src/responses.rs, add:
#[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:
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
FollowRemoteRequesttoapi-types
In crates/api-types/src/requests.rs, add:
#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FollowRemoteRequest {
pub handle: String,
}
- Step 4: Run tests to see them fail
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:
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:
pub mod federation;
- Step 7: Mount routes
In crates/presentation/src/routes.rs, add these two routes inside let api_routes = Router::new():
.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
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
cargo check 2>&1 | tail -15
Expected: no errors.
- Step 10: Commit
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:
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:
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
RemoteUserCardcomponent
Create thoughts-frontend/components/remote-user-card.tsx:
"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
UserAvatarprops
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.tsxto detect handles and show remote result
Replace the file with:
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: "{query}"
</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
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
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:
- ✅
FederationActionPorttrait withlookup_actor+follow_remote— Task 1 - ✅
avatar_urlonRemoteActor— Task 1 - ✅
ExternalServiceerror variant — Task 1 - ✅
ActivityPubServiceimpl — Task 2 - ✅ Bootstrap refactor +
AppState.federation— Task 3 - ✅
RemoteActorResponse+FollowRemoteRequest— Task 4 - ✅
/federation/lookup+/federation/followendpoints — Task 4 - ✅ Error mapping (ExternalService → 502) — Task 3
- ✅ Frontend API client additions — Task 5
- ✅ Handle detection regex in search page — Task 5
- ✅
RemoteUserCardcomponent — Task 5
Placeholder check: None found.
Type consistency check:
RemoteActor.avatar_url: Option<String>used in Task 1, mapped fromDbActor.avatar_url: Option<Url>in Task 2 via.map(|u| u.to_string())✅FollowRemoteRequest.handle→follow_remote(&uid, &body.handle)✅RemoteActorResponsefields matchRemoteActordomain model fields ✅- Frontend
RemoteActorSchemacamelCase fields match#[serde(rename_all = "camelCase")]onRemoteActorResponse✅ UserId::inner()— verified as an assumption in Task 2 Step 4 with an explicit check step ✅