Compare commits
6 Commits
74eeb9fcb9
...
a460428be1
| Author | SHA1 | Date | |
|---|---|---|---|
| a460428be1 | |||
| 95dea06c55 | |||
| c085067318 | |||
| d831784489 | |||
| 4c203bed1d | |||
| 21b8684608 |
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 }
|
||||||
|
|||||||
89
thoughts-frontend/app/about/fediverse/page.tsx
Normal file
89
thoughts-frontend/app/about/fediverse/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "About the Fediverse",
|
||||||
|
description:
|
||||||
|
"Learn what the fediverse is, how ActivityPub works, and how to connect with Thoughts users from any compatible app.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
value: "what-is-fediverse",
|
||||||
|
title: "What is the Fediverse?",
|
||||||
|
content:
|
||||||
|
"The fediverse is a network of independent social apps that talk to each other using shared protocols. Think of it like email — you can send from Gmail to Yahoo because both support the same standards. In the fediverse, people on Mastodon can follow people on Thoughts, Pixelfed, or any other compatible app. No single company owns it; each server runs independently.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "what-is-activitypub",
|
||||||
|
title: "What is ActivityPub?",
|
||||||
|
content:
|
||||||
|
"ActivityPub is the open protocol (a W3C standard) that makes the fediverse possible. It defines how servers send posts, follows, likes, and other interactions to each other. Thoughts is built on ActivityPub, which means anyone on a compatible app can follow and interact with users here.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "your-handle",
|
||||||
|
title: "Your handle explained",
|
||||||
|
content:
|
||||||
|
"Your full fediverse handle looks like @username@thoughts.gabrielkaszewski.dev. The first part is your username on this server; the second part is the server itself. When someone on another fediverse app wants to find you, they type your full handle into their search bar — exactly like an email address.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "how-to-follow",
|
||||||
|
title: "How to follow someone here from another app",
|
||||||
|
content:
|
||||||
|
"In your fediverse app (Mastodon, Pixelfed, Akkoma, etc.), open the search and type the full handle: @username@thoughts.gabrielkaszewski.dev. Hit follow. Their posts will appear in your home feed. You don't need a Thoughts account to follow someone here.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "what-you-can-do",
|
||||||
|
title: "What you can do from other apps",
|
||||||
|
content:
|
||||||
|
"From any compatible fediverse app you can follow Thoughts users and reply to their posts — both work across the network. Thoughts is focused on short-form text, so the experience is intentionally simple.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "how-thoughts-is-different",
|
||||||
|
title: "How Thoughts is different",
|
||||||
|
content:
|
||||||
|
"Thoughts is deliberately text-only. Posts from local users are capped at 128 characters. There are no polls, DMs, or media uploads — by design, not limitation. When posts arrive from other instances they are displayed as text; media attachments are noted but not shown. The goal is a fast, focused reading experience.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "movies-diary",
|
||||||
|
title: "Special integration: movies.gabrielkaszewski.dev",
|
||||||
|
content:
|
||||||
|
"Thoughts has first-class support for movies-diary, a companion fediverse app for logging films. When someone you follow on movies-diary posts a review, rating, or watchlist entry, it appears in your Thoughts feed as a rich card — poster, title, year, and rating — rather than raw text. Just follow their movies-diary handle like any other fediverse account and it works automatically.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function FediversePage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl px-4 py-12">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">The Fediverse</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">
|
||||||
|
Thoughts is part of an open, decentralised social network. Here's how it works.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Accordion type="multiple" className="space-y-3">
|
||||||
|
{SECTIONS.map(({ value, title, content }) => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className="bg-card/80 backdrop-blur-lg rounded-xl border border-white/10 shadow overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionItem value={value} className="border-0 px-4">
|
||||||
|
<AccordionTrigger className="text-base font-semibold hover:no-underline">
|
||||||
|
{title}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PendingRequests } from "@/components/federation/pending-requests";
|
import { PendingRequests } from "@/components/federation/pending-requests";
|
||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface ProfilePageProps {
|
interface ProfilePageProps {
|
||||||
params: Promise<{ username: string }>;
|
params: Promise<{ username: string }>;
|
||||||
@@ -197,9 +199,19 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</p>
|
</p>
|
||||||
{fediverseHandle && (
|
{fediverseHandle && (
|
||||||
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all break-all">
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
{fediverseHandle}
|
<p className="text-xs text-muted-foreground/70 font-mono break-all">
|
||||||
</p>
|
{fediverseHandle}
|
||||||
|
</p>
|
||||||
|
<CopyButton text={fediverseHandle} />
|
||||||
|
<Link
|
||||||
|
href="/about/fediverse"
|
||||||
|
title="What is the Fediverse?"
|
||||||
|
className="inline-flex items-center text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
46
thoughts-frontend/components/copy-button.tsx
Normal file
46
thoughts-frontend/components/copy-button.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ text, className }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (copied) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// clipboard unavailable — silently no-op
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,15 @@ export function MainNav() {
|
|||||||
>
|
>
|
||||||
Discover
|
Discover
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about/fediverse"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname === "/about/fediverse" ? "text-foreground" : "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Fediverse
|
||||||
|
</Link>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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