feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
781
docs/superpowers/plans/2026-05-15-federation-gaps-2.md
Normal file
781
docs/superpowers/plans/2026-05-15-federation-gaps-2.md
Normal file
@@ -0,0 +1,781 @@
|
||||
# Federation Gaps — Round 2 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:** Fix seven federation gaps: HTML content format, hashtag federation, Undo(Like) inbound, Update(Actor) on profile change, @mention notifications, remote posts in home feed, and orphaned reply parent display.
|
||||
|
||||
**Architecture:** Backend changes span the AP adapter layer (activities.rs, service.rs, handler.rs), application layer (use cases, event service), and postgres adapter (feed.rs). Frontend changes are limited to api.ts and thought-card.tsx. All changes follow the existing hexagonal pattern — no business logic in presentation, domain events for cross-cutting concerns.
|
||||
|
||||
**Tech Stack:** Rust / axum / sqlx / activitypub_federation crate; Next.js 15 / TypeScript / Zod.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| Task | File | Change |
|
||||
|------|------|--------|
|
||||
| 1 | `crates/adapters/activitypub-base/src/service.rs` | Wrap content in `<p>` tags with HTML escaping |
|
||||
| 2 | `crates/adapters/activitypub/src/note.rs` | Add `tag` field to ThoughtNote |
|
||||
| 2 | `crates/adapters/activitypub-base/src/service.rs` | Extract hashtags and add to Note JSON |
|
||||
| 3 | `crates/adapters/activitypub-base/src/activities.rs` | Add "Like" arm to UndoActivity::receive |
|
||||
| 4 | `crates/domain/src/ports.rs` | Add `broadcast_actor_update` to OutboundFederationPort |
|
||||
| 4 | `crates/domain/src/events.rs` | Add `ProfileUpdated` variant |
|
||||
| 4 | `crates/domain/src/testing.rs` | Add SpyPort stub for broadcast_actor_update |
|
||||
| 4 | `crates/application/src/use_cases/profile.rs` | Publish ProfileUpdated from update_profile |
|
||||
| 4 | `crates/application/src/services/federation_event.rs` | Handle ProfileUpdated → broadcast_actor_update |
|
||||
| 4 | `crates/adapters/activitypub-base/src/service.rs` | Implement broadcast_actor_update port method |
|
||||
| 5 | `crates/adapters/activitypub/src/note.rs` | Add `tag` deserialization field |
|
||||
| 5 | `crates/adapters/activitypub-base/src/content.rs` | Add `on_mention` to ApObjectHandler |
|
||||
| 5 | `crates/adapters/activitypub/src/handler.rs` | Parse Mention tags, implement on_mention |
|
||||
| 5 | `crates/domain/src/events.rs` | Add `MentionReceived` variant |
|
||||
| 5 | `crates/domain/src/testing.rs` | No-op on_mention in TestStore impl |
|
||||
| 5 | `crates/application/src/services/notification_event.rs` | Handle MentionReceived |
|
||||
| 6 | `crates/adapters/postgres/src/feed.rs` | Extend home_feed SQL to include federation_following |
|
||||
| 7 | `crates/api-types/src/responses.rs` | Add `in_reply_to_url` to ThoughtResponse |
|
||||
| 7 | `crates/presentation/src/handlers/feed.rs` | Map in_reply_to_url into response |
|
||||
| 7 | `thoughts-frontend/lib/api.ts` | Add replyToUrl to ThoughtSchema |
|
||||
| 7 | `thoughts-frontend/components/thought-card.tsx` | Show external reply link when replyToUrl set |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: HTML content in outbound Notes
|
||||
|
||||
Mastodon and other AP servers expect HTML, not plain text. Wrap content in `<p>` tags and escape HTML entities. Multi-paragraph posts (newlines) get multiple `<p>` elements.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs` (function `thought_note_json`)
|
||||
|
||||
- [ ] **Step 1: Add a private HTML-escaping helper near the top of service.rs**
|
||||
|
||||
Read `crates/adapters/activitypub-base/src/service.rs`. Find `fn thought_note_json`. Add this private function just before it:
|
||||
|
||||
```rust
|
||||
fn content_to_html(text: &str) -> String {
|
||||
let escaped = text
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """);
|
||||
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
||||
if paragraphs.is_empty() {
|
||||
format!("<p>{}</p>", escaped)
|
||||
} else {
|
||||
paragraphs
|
||||
.iter()
|
||||
.map(|p| format!("<p>{}</p>", p))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Use `content_to_html` in `thought_note_json`**
|
||||
|
||||
In `thought_note_json`, find:
|
||||
```rust
|
||||
"content": thought.content.as_str(),
|
||||
```
|
||||
Replace with:
|
||||
```rust
|
||||
"content": content_to_html(thought.content.as_str()),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify compilation**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub-base/src/service.rs
|
||||
git commit -m "fix(ap): wrap outbound Note content in HTML paragraph tags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Hashtag federation
|
||||
|
||||
Outbound Notes must include a `tag` array with Hashtag objects so Mastodon can index posts by hashtag. Extract `#word` patterns from content and add to the Note JSON.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs` (`thought_note_json`)
|
||||
|
||||
- [ ] **Step 1: Add a hashtag-extraction helper in service.rs**
|
||||
|
||||
Add this function near `content_to_html` (already added in Task 1):
|
||||
|
||||
```rust
|
||||
fn extract_hashtag_tags(content: &str, base_url: &str) -> Vec<serde_json::Value> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut tags = Vec::new();
|
||||
for word in content.split_whitespace() {
|
||||
let tag = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '#');
|
||||
if let Some(name) = tag.strip_prefix('#') {
|
||||
if !name.is_empty() && seen.insert(name.to_lowercase()) {
|
||||
let lower = name.to_lowercase();
|
||||
tags.push(serde_json::json!({
|
||||
"type": "Hashtag",
|
||||
"name": format!("#{}", lower),
|
||||
"href": format!("{}/tags/{}", base_url, lower),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add hashtag tags to the Note JSON in `thought_note_json`**
|
||||
|
||||
In `thought_note_json`, after the closing `}` of `let mut note = serde_json::json!({...})`, add:
|
||||
|
||||
```rust
|
||||
let hashtag_tags = extract_hashtag_tags(thought.content.as_str(), base_url);
|
||||
if !hashtag_tags.is_empty() {
|
||||
note["tag"] = serde_json::json!(hashtag_tags);
|
||||
}
|
||||
```
|
||||
|
||||
Note: `base_url` is already a parameter of `thought_note_json(&self, thought, local_actor, base_url)` — use it directly.
|
||||
|
||||
- [ ] **Step 3: Verify compilation**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub-base/src/service.rs
|
||||
git commit -m "feat(ap): add hashtag tag array to outbound Notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Undo(Like) inbound handler
|
||||
|
||||
When a remote user unlikes a local post, we should acknowledge it. Add a "Like" arm to `UndoActivity::receive` that calls `on_unlike` on the object handler. The `on_unlike` impl will be a no-op (we don't store remote likes in the likes table, only notifications — removing them requires more infrastructure). This prevents the "ignoring Undo of unknown activity type" log spam.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub-base/src/activities.rs`
|
||||
- Modify: `crates/adapters/activitypub-base/src/content.rs`
|
||||
- Modify: `crates/adapters/activitypub/src/handler.rs`
|
||||
|
||||
- [ ] **Step 1: Add `on_unlike` to `ApObjectHandler` trait in `content.rs`**
|
||||
|
||||
Read `crates/adapters/activitypub-base/src/content.rs`. Find `ApObjectHandler`. Add after `on_announce_received`:
|
||||
|
||||
```rust
|
||||
/// Called when a remote actor removes a Like from a local thought.
|
||||
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add no-op `on_unlike` to `ThoughtsObjectHandler` in `handler.rs`**
|
||||
|
||||
Read `crates/adapters/activitypub/src/handler.rs`. Add after `on_announce_received`:
|
||||
|
||||
```rust
|
||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add "Like" arm to `UndoActivity::receive` in `activities.rs`**
|
||||
|
||||
Read `crates/adapters/activitypub-base/src/activities.rs`. Find `UndoActivity::receive`. Find the `match obj_type` block. Add before the `other =>` catch-all:
|
||||
|
||||
```rust
|
||||
"Like" => {
|
||||
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
|
||||
&& let Ok(obj_url) = Url::parse(obj_url_str)
|
||||
&& obj_url.host_str().unwrap_or("") == data.domain
|
||||
{
|
||||
data.object_handler
|
||||
.on_unlike(&obj_url, self.actor.inner())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to process unlike");
|
||||
});
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify compilation**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub-base/src/activities.rs \
|
||||
crates/adapters/activitypub-base/src/content.rs \
|
||||
crates/adapters/activitypub/src/handler.rs
|
||||
git commit -m "feat(ap): handle Undo(Like) inbound activity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update(Actor) outbound on profile change
|
||||
|
||||
When a user updates their profile (display name, bio, avatar), broadcast an `Update(Actor)` activity to their AP followers. The `broadcast_actor_update` method already exists on `ActivityPubService` — it just needs to be exposed as a port method and wired through the event system.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs` — add to OutboundFederationPort
|
||||
- Modify: `crates/domain/src/events.rs` — add ProfileUpdated variant
|
||||
- Modify: `crates/domain/src/testing.rs` — SpyPort stub
|
||||
- Modify: `crates/application/src/use_cases/profile.rs` — publish event
|
||||
- Modify: `crates/application/src/services/federation_event.rs` — handle event
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs` — implement port method
|
||||
|
||||
- [ ] **Step 1: Add `ProfileUpdated` to `DomainEvent` in `crates/domain/src/events.rs`**
|
||||
|
||||
Read the file. Add to the enum:
|
||||
|
||||
```rust
|
||||
ProfileUpdated { user_id: UserId },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `broadcast_actor_update` to `OutboundFederationPort` in `crates/domain/src/ports.rs`**
|
||||
|
||||
Find `OutboundFederationPort`. Add after `broadcast_undo_like`:
|
||||
|
||||
```rust
|
||||
/// Broadcast Update(Actor) to all accepted followers when a user updates their profile.
|
||||
async fn broadcast_actor_update(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), DomainError>;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add stub to `SpyPort` in `crates/application/src/services/federation_event.rs`**
|
||||
|
||||
Find `SpyPort` struct. Add field:
|
||||
```rust
|
||||
actor_updated: Mutex<Vec<UserId>>,
|
||||
```
|
||||
|
||||
Find `impl OutboundFederationPort for SpyPort`. Add:
|
||||
```rust
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.actor_updated.lock().unwrap().push(user_id.clone());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `EventPublisher` to `update_profile` use case in `crates/application/src/use_cases/profile.rs`**
|
||||
|
||||
Read the file. Find `pub async fn update_profile(...)`. Add `events: &dyn EventPublisher` as a parameter and import it. Publish `ProfileUpdated` after the update:
|
||||
|
||||
```rust
|
||||
pub async fn update_profile(
|
||||
users: &dyn UserRepository,
|
||||
events: &dyn EventPublisher,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
users
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await?;
|
||||
events
|
||||
.publish(&DomainEvent::ProfileUpdated {
|
||||
user_id: user_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Make sure `DomainEvent` and `EventPublisher` are imported at the top of profile.rs. Check the existing imports and add what's missing.
|
||||
|
||||
- [ ] **Step 5: Update all callers of `update_profile` to pass `&*s.events`**
|
||||
|
||||
`update_profile` is called from `crates/presentation/src/handlers/users.rs`. Read that file. Find the `patch_profile` handler call to `update_profile`. Add `&*s.events` as the second argument:
|
||||
|
||||
```rust
|
||||
update_profile(
|
||||
&*s.users,
|
||||
&*s.events,
|
||||
&uid,
|
||||
body.display_name,
|
||||
body.bio,
|
||||
body.avatar_url,
|
||||
body.header_url,
|
||||
body.custom_css,
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement `broadcast_actor_update` port method in `ActivityPubService` in `crates/adapters/activitypub-base/src/service.rs`**
|
||||
|
||||
Find `impl domain::ports::OutboundFederationPort for ActivityPubService`. Add after `broadcast_undo_like`:
|
||||
|
||||
```rust
|
||||
async fn broadcast_actor_update(
|
||||
&self,
|
||||
user_id: &domain::value_objects::UserId,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
self.broadcast_actor_update(user_id.as_uuid())
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
Note: this calls the existing private `broadcast_actor_update(uuid)` method on `ActivityPubService`.
|
||||
|
||||
- [ ] **Step 7: Handle `ProfileUpdated` in `federation_event.rs`**
|
||||
|
||||
Find the `match event` block. Add before the catch-all `_ => Ok(())`:
|
||||
|
||||
```rust
|
||||
DomainEvent::ProfileUpdated { user_id } => {
|
||||
self.ap.broadcast_actor_update(user_id).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify build and tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
|
||||
cargo test -p domain -p application 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/events.rs \
|
||||
crates/domain/src/ports.rs \
|
||||
crates/domain/src/testing.rs \
|
||||
crates/application/src/use_cases/profile.rs \
|
||||
crates/application/src/services/federation_event.rs \
|
||||
crates/presentation/src/handlers/users.rs \
|
||||
crates/adapters/activitypub-base/src/service.rs
|
||||
git commit -m "feat(ap): broadcast Update(Actor) when user updates their profile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: @mention notification
|
||||
|
||||
When a remote Note arrives with a Mention tag pointing to a local user, create a notification. The Note's `tag` array contains objects like `{"type":"Mention","href":"https://our.instance/users/{uuid}","name":"@user@domain"}`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub/src/note.rs` — add tag field
|
||||
- Modify: `crates/adapters/activitypub-base/src/content.rs` — add on_mention to trait
|
||||
- Modify: `crates/adapters/activitypub/src/handler.rs` — parse tags, implement on_mention
|
||||
- Modify: `crates/domain/src/events.rs` — add MentionReceived
|
||||
- Modify: `crates/domain/src/testing.rs` — no-op on_mention
|
||||
- Modify: `crates/application/src/services/notification_event.rs` — handle MentionReceived
|
||||
|
||||
- [ ] **Step 1: Add `tag` field to `ThoughtNote` in `crates/adapters/activitypub/src/note.rs`**
|
||||
|
||||
Read the file. Add to the `ThoughtNote` struct:
|
||||
|
||||
```rust
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub tag: Vec<serde_json::Value>,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `MentionReceived` to `DomainEvent` in `crates/domain/src/events.rs`**
|
||||
|
||||
Add to the enum:
|
||||
|
||||
```rust
|
||||
MentionReceived {
|
||||
thought_id: ThoughtId,
|
||||
mentioned_user_id: UserId,
|
||||
author_user_id: UserId,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `on_mention` to `ApObjectHandler` in `crates/adapters/activitypub-base/src/content.rs`**
|
||||
|
||||
Add after `on_unlike`:
|
||||
|
||||
```rust
|
||||
/// Called once per @mention of a local user in a remote Note.
|
||||
/// `thought_ap_id` is the AP URL of the Note, `mentioned_user_id` is the UUID
|
||||
/// of the local user being mentioned, `actor_url` is the remote author's AP URL.
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &Url,
|
||||
) -> anyhow::Result<()>;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add no-op `on_mention` to `TestStore`'s `ApObjectHandler` impl in `crates/domain/src/testing.rs`**
|
||||
|
||||
Note: `TestStore` does NOT implement `ApObjectHandler` — that's `ThoughtsObjectHandler` in the activitypub adapter. Instead, find if there is a test double or just implement in handler.rs directly (step 5 below covers it).
|
||||
|
||||
- [ ] **Step 5: Implement `on_mention` in `ThoughtsObjectHandler` in `crates/adapters/activitypub/src/handler.rs`**
|
||||
|
||||
Add after `on_unlike`:
|
||||
|
||||
```rust
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &url::Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
// Resolve remote author to a local user ID.
|
||||
let author_user_id = match self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Extract thought UUID from /thoughts/{uuid} path.
|
||||
let thought_uuid = thought_ap_id
|
||||
.path()
|
||||
.strip_prefix("/thoughts/")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::MentionReceived {
|
||||
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
|
||||
author_user_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Parse Mention tags and call `on_mention` in `ThoughtsObjectHandler::on_create`**
|
||||
|
||||
Find `on_create`. After the `accept_note(...)` call, add:
|
||||
|
||||
```rust
|
||||
// Fire mention notifications for any local @mentions in the note's tag array.
|
||||
let local_domain = self.urls.base_url().host_str().unwrap_or("");
|
||||
for tag in ¬e.tag {
|
||||
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
|
||||
continue;
|
||||
}
|
||||
let href = match tag.get("href").and_then(|h| h.as_str()) {
|
||||
Some(h) => h,
|
||||
None => continue,
|
||||
};
|
||||
let href_url = match url::Url::parse(href) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Only process mentions of local users (UUID-based /users/{uuid} paths).
|
||||
if href_url.host_str().unwrap_or("") != local_domain {
|
||||
continue;
|
||||
}
|
||||
let user_uuid = href_url
|
||||
.path()
|
||||
.strip_prefix("/users/")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
if let Some(uuid) = user_uuid {
|
||||
self.on_mention(ap_id, uuid, actor_url)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to process mention notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `self.urls.base_url()` — check `ThoughtsUrls` for how to get the base URL `Url`. If not available, parse `self.urls` fields or add a helper. Check the `ThoughtsUrls` struct in `crates/adapters/activitypub/src/urls.rs`.
|
||||
|
||||
- [ ] **Step 7: Handle `MentionReceived` in `crates/application/src/services/notification_event.rs`**
|
||||
|
||||
Find the `match event` block. Add before the `_ => Ok(())` catch-all:
|
||||
|
||||
```rust
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: mentioned_user_id.clone(),
|
||||
notification_type: NotificationType::Mention,
|
||||
from_user_id: Some(author_user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
Make sure `NotificationType::Mention` is a variant — check `crates/domain/src/models/notification.rs`. It already has `Mention` variant.
|
||||
|
||||
- [ ] **Step 8: Verify build and tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10
|
||||
cargo test -p domain -p application 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub/src/note.rs \
|
||||
crates/adapters/activitypub-base/src/content.rs \
|
||||
crates/adapters/activitypub/src/handler.rs \
|
||||
crates/domain/src/events.rs \
|
||||
crates/application/src/services/notification_event.rs
|
||||
git commit -m "feat(ap): @mention notification from inbound remote Notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Remote posts in home feed
|
||||
|
||||
The `home_feed` SQL currently only includes thoughts from users in the `follows` table (local follows). Remote follows are in `federation_following`, so remote users' posts never appear. Extend the SQL to also include thoughts from users whose AP URL is in `federation_following` for the viewer.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/postgres/src/feed.rs`
|
||||
|
||||
- [ ] **Step 1: Read `crates/adapters/postgres/src/feed.rs` in full**
|
||||
|
||||
Focus on `fn feed_select(viewer: Option<uuid::Uuid>) -> String` and `async fn home_feed(...)`.
|
||||
|
||||
Key insight: `feed_select` embeds `viewer` UUID directly into the SQL string (not as a bind parameter). The home_feed SQL uses `$1` (following_ids), `$2` (limit), `$3` (offset) as bind params.
|
||||
|
||||
- [ ] **Step 2: Add `follower` parameter to `feed_select`**
|
||||
|
||||
Change the signature to:
|
||||
```rust
|
||||
fn feed_select(viewer: Option<uuid::Uuid>, follower: Option<uuid::Uuid>) -> String
|
||||
```
|
||||
|
||||
At the top of the function body, generate a federation following subquery:
|
||||
```rust
|
||||
let federation_clause = match follower {
|
||||
Some(fid) => format!(
|
||||
"OR t.user_id IN (
|
||||
SELECT u2.id FROM users u2
|
||||
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||
WHERE ff.local_user_id = '{fid}'
|
||||
)"
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
```
|
||||
|
||||
This string is used in step 3's WHERE clause modification.
|
||||
|
||||
Since `feed_select` generates only the SELECT part (not WHERE), the `federation_clause` needs to be returned somehow. Options:
|
||||
- Return a tuple `(select_str, federation_clause)` from `feed_select`
|
||||
- Or add a separate helper `fn federation_following_clause(follower: Option<uuid::Uuid>) -> String`
|
||||
|
||||
Use option B — separate helper — to avoid changing `feed_select`'s return type:
|
||||
|
||||
```rust
|
||||
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
|
||||
match follower {
|
||||
Some(fid) => format!(
|
||||
" OR t.user_id IN (
|
||||
SELECT u2.id FROM users u2
|
||||
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||
WHERE ff.local_user_id = '{fid}'
|
||||
)"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `feed_select` signature unchanged.
|
||||
|
||||
- [ ] **Step 3: Modify `home_feed` to use `federation_following_clause`**
|
||||
|
||||
Find the `home_feed` method. The viewer_id is the feed owner (the logged-in user), which is also the person whose federation_following we want.
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
// ...
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility != 'direct'",
|
||||
)
|
||||
```
|
||||
|
||||
With:
|
||||
```rust
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let fed_clause = federation_following_clause(viewer);
|
||||
let total: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
||||
fed_clause
|
||||
))
|
||||
```
|
||||
|
||||
And replace:
|
||||
```rust
|
||||
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
```
|
||||
|
||||
With:
|
||||
```rust
|
||||
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
|
||||
```
|
||||
|
||||
The rest of the bindings (`$1`, `$2`, `$3`) stay unchanged.
|
||||
|
||||
- [ ] **Step 4: Verify compilation**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build -p postgres 2>&1 | grep "^error" | head -5
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/postgres/src/feed.rs
|
||||
git commit -m "feat(feed): include remote following posts in home feed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Reply parent display + API field
|
||||
|
||||
Remote posts that are replies show without context because:
|
||||
1. `ThoughtResponse` doesn't expose `in_reply_to_url` (the external URL of the parent)
|
||||
2. The frontend doesn't link to the parent when it's external
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/api-types/src/responses.rs`
|
||||
- Modify: `crates/presentation/src/handlers/feed.rs` (or wherever `to_thought_response` is defined)
|
||||
- Modify: `thoughts-frontend/lib/api.ts`
|
||||
- Modify: `thoughts-frontend/components/thought-card.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `in_reply_to_url` to `ThoughtResponse` in `crates/api-types/src/responses.rs`**
|
||||
|
||||
Read the file. Find `ThoughtResponse` struct. Add after `reply_to_id`:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "replyToUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub in_reply_to_url: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map `in_reply_to_url` in the response builder**
|
||||
|
||||
Find where `ThoughtResponse` is constructed from a `Thought` (search for `ThoughtResponse {` or `to_thought_response`). Add the mapping:
|
||||
|
||||
```rust
|
||||
in_reply_to_url: thought.in_reply_to_url.clone(),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify backend compilation**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `replyToUrl` to `ThoughtSchema` in `thoughts-frontend/lib/api.ts`**
|
||||
|
||||
Find `ThoughtSchema`. Add:
|
||||
|
||||
```typescript
|
||||
replyToUrl: z.string().url().nullable().optional(),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `thought-card.tsx` to show external reply link**
|
||||
|
||||
Read `thoughts-frontend/components/thought-card.tsx`. Find the section that renders `thought.replyToId`. It currently shows "Replying to parent thought" with a hash link only when `isReply` is true.
|
||||
|
||||
Add an external reply link for when the thought has a `replyToUrl` but no local `replyToId`:
|
||||
|
||||
```tsx
|
||||
{thought.replyToId && isReply && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||||
<span>
|
||||
Replying to{" "}
|
||||
<Link
|
||||
href={`#${thought.replyToId}`}
|
||||
className="hover:underline text-primary text-shadow-sm"
|
||||
>
|
||||
parent thought
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!thought.replyToId && thought.replyToUrl && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||||
<span>
|
||||
Replying to{" "}
|
||||
<a
|
||||
href={thought.replyToUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary text-shadow-sm"
|
||||
>
|
||||
original post ↗
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Type check frontend**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -5
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Final build + tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
|
||||
cargo test -p domain -p application 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/api-types/src/responses.rs \
|
||||
thoughts-frontend/lib/api.ts \
|
||||
thoughts-frontend/components/thought-card.tsx
|
||||
git commit -m "feat: expose replyToUrl in API + show external parent link on remote reply posts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Task 5 (mentions)**: `self.urls.base_url()` — if `ThoughtsUrls` doesn't expose the base URL as a `Url`, parse it from `self.urls.base_url` string. Check `crates/adapters/activitypub/src/urls.rs` for the exact field.
|
||||
- **Task 6 (feed)**: The embedded UUID in the SQL is a UUID type (hex + hyphens only), safe to format-string without SQL injection risk.
|
||||
- **Task 7 (reply)**: The `to_thought_response` builder might be in `handlers/feed.rs`, `handlers/thoughts.rs`, or a shared module — search the codebase for where `ThoughtResponse` is constructed.
|
||||
- **Profile update (Task 4)**: If tests in `application` call `update_profile` directly, they'll need to pass a `TestStore` as the `events` parameter (TestStore implements EventPublisher).
|
||||
Reference in New Issue
Block a user