feat: update dependencies to k-ap v0.1.7 and add profileHref utility for user links
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
/target
|
/target
|
||||||
/docs/superpowers/
|
/docs/superpowers/
|
||||||
|
/media
|
||||||
/media
|
|
||||||
|
|||||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -265,6 +265,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1050,6 +1051,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2013,8 +2015,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.0"
|
version = "0.1.7"
|
||||||
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.3#7901b29f7c09415e82f7f098f89c1df6b86bbfd3"
|
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.7#699258f830922830df956db8e5dea739ee1642aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2563,6 +2565,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"postgres",
|
"postgres",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -14,36 +14,6 @@ use domain::ports::{EventPublisher, TagRepository};
|
|||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use k_ap::ApObjectHandler;
|
use k_ap::ApObjectHandler;
|
||||||
|
|
||||||
fn extract_note_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
|
||||||
const STANDARD: &[&str] = &[
|
|
||||||
"type",
|
|
||||||
"id",
|
|
||||||
"attributedTo",
|
|
||||||
"content",
|
|
||||||
"published",
|
|
||||||
"to",
|
|
||||||
"cc",
|
|
||||||
"inReplyTo",
|
|
||||||
"sensitive",
|
|
||||||
"summary",
|
|
||||||
"tag",
|
|
||||||
"url",
|
|
||||||
"@context",
|
|
||||||
"mediaType",
|
|
||||||
];
|
|
||||||
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
|
||||||
.as_object()?
|
|
||||||
.iter()
|
|
||||||
.filter(|(k, _)| !STANDARD.contains(&k.as_str()))
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
if extensions.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(serde_json::Value::Object(extensions))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
repo: Arc<dyn ActivityPubRepository>,
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
urls: ThoughtsUrls,
|
urls: ThoughtsUrls,
|
||||||
@@ -148,8 +118,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let note_extensions = extract_note_extensions(&object);
|
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
let author_id = self
|
let author_id = self
|
||||||
.repo
|
.repo
|
||||||
.intern_remote_actor(actor_url.as_str())
|
.intern_remote_actor(actor_url.as_str())
|
||||||
@@ -249,7 +221,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
_actor_url: &Url,
|
_actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
let Some((note, _)) = ThoughtNote::try_from_ap(object) else {
|
||||||
|
tracing::debug!(ap_id = %ap_id, "on_update: skipping non-Note object");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
self.repo
|
self.repo
|
||||||
.apply_note_update(ap_id.as_str(), ¬e.content)
|
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||||
.await
|
.await
|
||||||
@@ -440,46 +415,3 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod extract_tests {
|
|
||||||
use super::extract_note_extensions;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extracts_non_standard_fields() {
|
|
||||||
let obj = serde_json::json!({
|
|
||||||
"type": "Note",
|
|
||||||
"id": "https://example.com/notes/1",
|
|
||||||
"content": "hello",
|
|
||||||
"published": "2025-01-01T00:00:00Z",
|
|
||||||
"movieTitle": "Dune",
|
|
||||||
"rating": 5,
|
|
||||||
"posterUrl": "https://example.com/poster.jpg"
|
|
||||||
});
|
|
||||||
let ext = extract_note_extensions(&obj).unwrap();
|
|
||||||
assert_eq!(ext["movieTitle"], "Dune");
|
|
||||||
assert_eq!(ext["rating"], 5);
|
|
||||||
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
|
||||||
assert!(ext.get("type").is_none());
|
|
||||||
assert!(ext.get("content").is_none());
|
|
||||||
assert!(ext.get("id").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_for_standard_only_note() {
|
|
||||||
let obj = serde_json::json!({
|
|
||||||
"type": "Note",
|
|
||||||
"content": "hello",
|
|
||||||
"published": "2025-01-01T00:00:00Z",
|
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
"tag": []
|
|
||||||
});
|
|
||||||
assert!(extract_note_extensions(&obj).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_for_non_object() {
|
|
||||||
let obj = serde_json::json!("not an object");
|
|
||||||
assert!(extract_note_extensions(&obj).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ use k_ap::AS_PUBLIC;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
const STANDARD_NOTE_FIELDS: &[&str] = &[
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"attributedTo",
|
||||||
|
"content",
|
||||||
|
"published",
|
||||||
|
"to",
|
||||||
|
"cc",
|
||||||
|
"inReplyTo",
|
||||||
|
"sensitive",
|
||||||
|
"summary",
|
||||||
|
"tag",
|
||||||
|
"url",
|
||||||
|
"@context",
|
||||||
|
"mediaType",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn extract_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
||||||
|
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
||||||
|
.as_object()?
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !STANDARD_NOTE_FIELDS.contains(&k.as_str()))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
if extensions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::Value::Object(extensions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AP Note representing a Thought.
|
/// AP Note representing a Thought.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -42,6 +73,17 @@ pub struct ThoughtNoteInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ThoughtNote {
|
impl ThoughtNote {
|
||||||
|
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
|
||||||
|
pub fn try_from_ap(value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
|
||||||
|
if value.get("type").and_then(|v| v.as_str()) != Some("Note") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let extensions = extract_extensions(&value);
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.ok()
|
||||||
|
.map(|note| (note, extensions))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_extensions_picks_up_non_standard_fields() {
|
||||||
|
let obj = serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"id": "https://example.com/notes/1",
|
||||||
|
"content": "hello",
|
||||||
|
"published": "2025-01-01T00:00:00Z",
|
||||||
|
"movieTitle": "Dune",
|
||||||
|
"rating": 5,
|
||||||
|
"posterUrl": "https://example.com/poster.jpg"
|
||||||
|
});
|
||||||
|
let ext = extract_extensions(&obj).unwrap();
|
||||||
|
assert_eq!(ext["movieTitle"], "Dune");
|
||||||
|
assert_eq!(ext["rating"], 5);
|
||||||
|
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
||||||
|
assert!(ext.get("type").is_none());
|
||||||
|
assert!(ext.get("content").is_none());
|
||||||
|
assert!(ext.get("id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_extensions_returns_none_for_standard_only_note() {
|
||||||
|
let obj = serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"content": "hello",
|
||||||
|
"published": "2025-01-01T00:00:00Z",
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"tag": []
|
||||||
|
});
|
||||||
|
assert!(extract_extensions(&obj).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_extensions_returns_none_for_non_object() {
|
||||||
|
let obj = serde_json::json!("not an object");
|
||||||
|
assert!(extract_extensions(&obj).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_from_ap_returns_none_for_person() {
|
||||||
|
let person = serde_json::json!({ "type": "Person", "id": "https://example.com/users/1" });
|
||||||
|
assert!(ThoughtNote::try_from_ap(person).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_from_ap_returns_none_for_missing_type() {
|
||||||
|
let obj = serde_json::json!({ "content": "hello" });
|
||||||
|
assert!(ThoughtNote::try_from_ap(obj).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn note_serializes_with_public_audience() {
|
fn note_serializes_with_public_audience() {
|
||||||
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
|
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
|
||||||
|
|||||||
@@ -502,72 +502,29 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationLookupPort for ApFederationAdapter {
|
impl FederationLookupPort for ApFederationAdapter {
|
||||||
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
||||||
let normalized = handle.trim_start_matches('@');
|
let actor = self
|
||||||
let at = normalized
|
.inner
|
||||||
.rfind('@')
|
.lookup_actor_by_handle(handle)
|
||||||
.ok_or_else(|| DomainError::InvalidInput("handle must be user@domain".into()))?;
|
|
||||||
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
|
|
||||||
|
|
||||||
let wf_url = format!(
|
|
||||||
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
|
|
||||||
domain_str, user, domain_str
|
|
||||||
);
|
|
||||||
let wf: serde_json::Value = reqwest::Client::new()
|
|
||||||
.get(&wf_url)
|
|
||||||
.header("Accept", "application/jrd+json, application/json")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
|
||||||
.json()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
let self_href = wf["links"]
|
|
||||||
.as_array()
|
|
||||||
.and_then(|links| {
|
|
||||||
links.iter().find(|l| {
|
|
||||||
l["rel"].as_str() == Some("self")
|
|
||||||
&& l["type"].as_str() == Some("application/activity+json")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.and_then(|l| l["href"].as_str())
|
|
||||||
.ok_or(DomainError::NotFound)?
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
let actor_json: serde_json::Value = reqwest::Client::new()
|
|
||||||
.get(&self_href)
|
|
||||||
.header("Accept", "application/activity+json")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
|
||||||
|
|
||||||
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
|
|
||||||
let preferred_username = actor_json["preferredUsername"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let domain_part = url::Url::parse(&ap_url)
|
|
||||||
.ok()
|
|
||||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let full_handle = format!("{}@{}", preferred_username, domain_part);
|
|
||||||
|
|
||||||
Ok(DomainRemoteActor {
|
Ok(DomainRemoteActor {
|
||||||
url: ap_url.clone(),
|
url: actor.ap_url.to_string(),
|
||||||
handle: full_handle,
|
handle: actor.handle,
|
||||||
display_name: actor_json["name"].as_str().map(|s| s.to_string()),
|
display_name: actor.display_name,
|
||||||
avatar_url: actor_json["icon"]["url"].as_str().map(|s| s.to_string()),
|
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||||
outbox_url: actor_json["outbox"].as_str().map(|s| s.to_string()),
|
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
|
||||||
last_fetched_at: chrono::Utc::now(),
|
last_fetched_at: chrono::Utc::now(),
|
||||||
bio: actor_json["summary"].as_str().map(|s| s.to_string()),
|
bio: actor.bio,
|
||||||
banner_url: actor_json["image"]["url"].as_str().map(|s| s.to_string()),
|
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||||
also_known_as: None,
|
also_known_as: actor.also_known_as,
|
||||||
followers_url: actor_json["followers"].as_str().map(|s| s.to_string()),
|
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
|
||||||
following_url: actor_json["following"].as_str().map(|s| s.to_string()),
|
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
|
||||||
attachment: vec![],
|
attachment: actor
|
||||||
|
.attachment
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| (f.name, f.value))
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ impl FederationEventService {
|
|||||||
{
|
{
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
_ => return Ok(()),
|
_ => {
|
||||||
|
tracing::debug!(thought_id = %thought_id, "federation: skipping ThoughtCreated (remote or non-public)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let user = match self.users.find_by_id(user_id).await? {
|
let user = match self.users.find_by_id(user_id).await? {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
@@ -58,6 +61,7 @@ impl FederationEventService {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Create(Note)");
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_create(
|
.broadcast_create(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -72,8 +76,7 @@ impl FederationEventService {
|
|||||||
thought_id,
|
thought_id,
|
||||||
user_id,
|
user_id,
|
||||||
} => {
|
} => {
|
||||||
// No DB lookup — thought is already deleted when this event fires.
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Delete");
|
||||||
// No locality guard: delete commands only reach local thoughts via the use case.
|
|
||||||
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
|
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
|
||||||
self.ap.broadcast_delete(user_id, &ap_id).await
|
self.ap.broadcast_delete(user_id, &ap_id).await
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,7 @@ impl FederationEventService {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Update(Note)");
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_update(
|
.broadcast_update(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -121,16 +125,19 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
// Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast.
|
|
||||||
let booster = match self.users.find_by_id(user_id).await? {
|
let booster = match self.users.find_by_id(user_id).await? {
|
||||||
Some(u) if u.local => u,
|
Some(u) if u.local => u,
|
||||||
_ => return Ok(()),
|
_ => {
|
||||||
|
tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let _ = booster;
|
let _ = booster;
|
||||||
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Announce");
|
||||||
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +149,7 @@ impl FederationEventService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Undo(Announce)");
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_undo_announce(user_id, &object_ap_id)
|
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||||
.await
|
.await
|
||||||
@@ -152,10 +160,12 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
// Only federate: local liker + remote thought (has ap_id) + author has inbox.
|
|
||||||
let liker = match self.users.find_by_id(user_id).await? {
|
let liker = match self.users.find_by_id(user_id).await? {
|
||||||
Some(u) if u.local => u,
|
Some(u) if u.local => u,
|
||||||
_ => return Ok(()),
|
_ => {
|
||||||
|
tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let _ = liker;
|
let _ = liker;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
@@ -164,12 +174,16 @@ impl FederationEventService {
|
|||||||
};
|
};
|
||||||
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return Ok(()), // local thought — no federation needed
|
None => {
|
||||||
|
tracing::debug!(thought_id = %thought_id, "federation: skipping LikeAdded (local thought)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Like");
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||||
.await
|
.await
|
||||||
@@ -196,12 +210,14 @@ impl FederationEventService {
|
|||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Undo(Like)");
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
DomainEvent::ProfileUpdated { user_id } => {
|
DomainEvent::ProfileUpdated { user_id } => {
|
||||||
|
tracing::info!(user_id = %user_id, "federation: broadcasting actor update");
|
||||||
self.ap.broadcast_actor_update(user_id).await
|
self.ap.broadcast_actor_update(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ impl NotificationEventService {
|
|||||||
if is_self_action(&thought.user_id, user_id) {
|
if is_self_action(&thought.user_id, user_id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Like");
|
||||||
self.notifications
|
self.notifications
|
||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
@@ -60,6 +61,7 @@ impl NotificationEventService {
|
|||||||
if is_self_action(&thought.user_id, user_id) {
|
if is_self_action(&thought.user_id, user_id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Boost");
|
||||||
self.notifications
|
self.notifications
|
||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
@@ -77,6 +79,7 @@ impl NotificationEventService {
|
|||||||
follower_id,
|
follower_id,
|
||||||
following_id,
|
following_id,
|
||||||
} => {
|
} => {
|
||||||
|
tracing::info!(from = %follower_id, to = %following_id, "notification: Follow");
|
||||||
self.notifications
|
self.notifications
|
||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
@@ -105,6 +108,7 @@ impl NotificationEventService {
|
|||||||
if is_self_action(&original.user_id, user_id) {
|
if is_self_action(&original.user_id, user_id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
tracing::info!(from = %user_id, to = %original.user_id, thought_id = %thought_id, "notification: Reply");
|
||||||
self.notifications
|
self.notifications
|
||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
@@ -123,6 +127,7 @@ impl NotificationEventService {
|
|||||||
mentioned_user_id,
|
mentioned_user_id,
|
||||||
author_user_id,
|
author_user_id,
|
||||||
} => {
|
} => {
|
||||||
|
tracing::info!(from = %author_user_id, to = %mentioned_user_id, thought_id = %thought_id, "notification: Mention");
|
||||||
self.notifications
|
self.notifications
|
||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ postgres = { workspace = true }
|
|||||||
postgres-search = { workspace = true }
|
postgres-search = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
|
||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ application = { workspace = true }
|
|||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
event-payload = { workspace = true }
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
postgres = { workspace = true }
|
postgres = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { User } from "@/lib/api";
|
import { User } from "@/lib/api";
|
||||||
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { formatDistanceToNow, format } from "date-fns";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { profileHref } from "@/lib/utils";
|
||||||
|
|
||||||
function isSafeImageUrl(url: string): boolean {
|
function isSafeImageUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +45,7 @@ function StarRating({ rating, max = 5 }: { rating: number; max?: number }) {
|
|||||||
export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
|
export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
|
||||||
const isWatchlist = meta.watchlistEntry === true;
|
const isWatchlist = meta.watchlistEntry === true;
|
||||||
const year = meta.releaseYear ? ` (${meta.releaseYear})` : "";
|
const year = meta.releaseYear ? ` (${meta.releaseYear})` : "";
|
||||||
|
const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true });
|
||||||
const watchedDate = meta.watchedAt
|
const watchedDate = meta.watchedAt
|
||||||
? new Date(meta.watchedAt).toLocaleDateString(undefined, {
|
? new Date(meta.watchedAt).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -49,57 +55,74 @@ export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card overflow-hidden">
|
<Card className="transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
|
||||||
<div className="flex gap-3 p-3">
|
<CardHeader className="flex flex-row items-center space-y-0 pb-3">
|
||||||
{/* Poster */}
|
<Link
|
||||||
<div className="shrink-0 w-16 h-24 rounded overflow-hidden bg-muted">
|
href={profileHref(author.username, author.local)}
|
||||||
{meta.posterUrl && isSafeImageUrl(meta.posterUrl) ? (
|
className="flex items-center gap-3 hover:opacity-80"
|
||||||
<img
|
>
|
||||||
src={meta.posterUrl}
|
<UserAvatar src={author.avatarUrl} alt={author.displayName ?? author.username} />
|
||||||
alt={meta.movieTitle}
|
<div className="flex flex-col min-w-0">
|
||||||
className="w-full h-full object-cover"
|
<span className="font-bold truncate">{author.displayName ?? author.username}</span>
|
||||||
/>
|
{!author.local && (
|
||||||
) : (
|
<span className="text-xs text-muted-foreground/70 truncate">
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs text-center px-1">
|
{author.username.startsWith("@") ? author.username : `@${author.username}`}
|
||||||
No poster
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
<time
|
||||||
|
dateTime={createdAt.toISOString()}
|
||||||
|
title={format(createdAt, "PPP p")}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{timeAgo}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 w-16 h-24 rounded overflow-hidden bg-muted">
|
||||||
|
{meta.posterUrl && isSafeImageUrl(meta.posterUrl) ? (
|
||||||
|
<img
|
||||||
|
src={meta.posterUrl}
|
||||||
|
alt={meta.movieTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs text-center px-1">
|
||||||
|
No poster
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm leading-tight">
|
||||||
|
{meta.movieTitle}
|
||||||
|
{year && <span className="font-normal text-muted-foreground">{year}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isWatchlist ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">📋 Want to watch</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{meta.rating !== undefined && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<StarRating rating={meta.rating} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watchedDate && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Watched {watchedDate}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.comment && (
|
||||||
|
<p className="text-sm mt-2 text-foreground/80 line-clamp-3">{meta.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
{/* Info */}
|
</Card>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-semibold text-sm leading-tight">
|
|
||||||
{meta.movieTitle}
|
|
||||||
{year && <span className="font-normal text-muted-foreground">{year}</span>}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isWatchlist ? (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">📋 Want to watch</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{meta.rating !== undefined && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<StarRating rating={meta.rating} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{watchedDate && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">Watched {watchedDate}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{meta.comment && (
|
|
||||||
<p className="text-sm mt-2 text-foreground/80 line-clamp-3">{meta.comment}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-3 py-2 border-t bg-muted/30 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>@{author.username}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{createdAt.toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { profileHref } from "@/lib/utils";
|
||||||
|
|
||||||
interface RemoteUserCardProps {
|
interface RemoteUserCardProps {
|
||||||
actor: {
|
actor: {
|
||||||
@@ -18,19 +19,6 @@ interface RemoteUserCardProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProfileHref(handle: string): string {
|
|
||||||
const apiDomain = process.env.NEXT_PUBLIC_API_URL
|
|
||||||
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
|
|
||||||
: null;
|
|
||||||
const clean = handle.startsWith("@") ? handle.slice(1) : handle;
|
|
||||||
const atIdx = clean.indexOf("@");
|
|
||||||
const domain = atIdx !== -1 ? clean.slice(atIdx + 1) : null;
|
|
||||||
const username = atIdx !== -1 ? clean.slice(0, atIdx) : clean;
|
|
||||||
return apiDomain && domain === apiDomain
|
|
||||||
? `/users/${username}`
|
|
||||||
: `/remote-actor?handle=@${clean}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -56,7 +44,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<Link
|
<Link
|
||||||
href={resolveProfileHref(actor.handle)}
|
href={profileHref(actor.handle, false)}
|
||||||
className="flex items-center gap-3 hover:opacity-80"
|
className="flex items-center gap-3 hover:opacity-80"
|
||||||
>
|
>
|
||||||
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />
|
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function RemoteUserProfile({
|
|||||||
|
|
||||||
<TabsContent value="followers" className="mt-4">
|
<TabsContent value="followers" className="mt-4">
|
||||||
<Connections
|
<Connections
|
||||||
handle={actor.handle}
|
handle={handle}
|
||||||
token={token}
|
token={token}
|
||||||
type="followers"
|
type="followers"
|
||||||
active={followersActive}
|
active={followersActive}
|
||||||
@@ -164,7 +164,7 @@ export function RemoteUserProfile({
|
|||||||
|
|
||||||
<TabsContent value="following" className="mt-4">
|
<TabsContent value="following" className="mt-4">
|
||||||
<Connections
|
<Connections
|
||||||
handle={actor.handle}
|
handle={handle}
|
||||||
token={token}
|
token={token}
|
||||||
type="following"
|
type="following"
|
||||||
active={followingActive}
|
active={followingActive}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
import { ThoughtForm } from "@/components/thought-form";
|
import { ThoughtForm } from "@/components/thought-form";
|
||||||
import { MovieCard } from "@/components/movie-card";
|
import { MovieCard } from "@/components/movie-card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, profileHref } from "@/lib/utils";
|
||||||
|
|
||||||
interface ThoughtCardProps {
|
interface ThoughtCardProps {
|
||||||
thought: Thought;
|
thought: Thought;
|
||||||
@@ -153,7 +153,7 @@ export function ThoughtCard({
|
|||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/users/${author.username}`}
|
href={profileHref(author.username, author.local)}
|
||||||
className="flex items-center gap-4 text-shadow-md"
|
className="flex items-center gap-4 text-shadow-md"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -164,6 +164,11 @@ export function ThoughtCard({
|
|||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{author.displayName || author.username}
|
{author.displayName || author.username}
|
||||||
</span>
|
</span>
|
||||||
|
{!author.local && (
|
||||||
|
<span className="text-xs text-muted-foreground/70 truncate">
|
||||||
|
{author.username.startsWith("@") ? author.username : `@${author.username}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<time
|
<time
|
||||||
dateTime={new Date(thought.createdAt).toISOString()}
|
dateTime={new Date(thought.createdAt).toISOString()}
|
||||||
title={format(new Date(thought.createdAt), "PPP p")}
|
title={format(new Date(thought.createdAt), "PPP p")}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cache } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
@@ -278,13 +279,14 @@ export const markAllNotificationsRead = (token: string) =>
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
export const lookupRemoteActor = cache((handle: string, token: string | null) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
`/users/lookup?handle=${encodeURIComponent(handle)}`,
|
`/users/lookup?handle=${encodeURIComponent(handle)}`,
|
||||||
{ next: { tags: [`remote-actor:${handle}`] } },
|
{ next: { tags: [`remote-actor:${handle}`] } },
|
||||||
RemoteActorSchema,
|
RemoteActorSchema,
|
||||||
token
|
token
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export const getRemoteActorPosts = (
|
export const getRemoteActorPosts = (
|
||||||
handle: string,
|
handle: string,
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export function fullFediverseHandle(handle: string, actorUrl: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the correct profile URL for an author.
|
||||||
|
* Local users go to /users/:username; remote actors go to /remote-actor?handle=. */
|
||||||
|
export function profileHref(username: string, local: boolean): string {
|
||||||
|
if (local) return `/users/${username}`;
|
||||||
|
const handle = username.startsWith("@") ? username : `@${username}`;
|
||||||
|
return `/remote-actor?handle=${encodeURIComponent(handle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
|
export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
|
||||||
const thoughtMap = new Map<string, Thought>();
|
const thoughtMap = new Map<string, Thought>();
|
||||||
thoughts.forEach((t) => thoughtMap.set(t.id, t));
|
thoughts.forEach((t) => thoughtMap.set(t.id, t));
|
||||||
|
|||||||
Reference in New Issue
Block a user