Compare commits

..

6 Commits

Author SHA1 Message Date
a460428be1 feat: update dependencies to k-ap v0.1.7 and add profileHref utility for user links
Some checks failed
lint / lint (push) Failing after 7m11s
test / unit (push) Successful in 16m59s
test / integration (push) Failing after 18m3s
2026-05-27 23:38:14 +02:00
95dea06c55 feat: add /about/fediverse info page with glass accordion panels 2026-05-27 23:38:14 +02:00
c085067318 feat: add Fediverse nav link 2026-05-27 23:38:14 +02:00
d831784489 feat: add copy handle button and fediverse info link to profile 2026-05-27 23:38:14 +02:00
4c203bed1d fix: handle clipboard errors and cleanup timeout in CopyButton 2026-05-27 23:38:14 +02:00
21b8684608 feat: add CopyButton client component 2026-05-27 23:38:14 +02:00
22 changed files with 413 additions and 227 deletions

3
.gitignore vendored
View File

@@ -2,5 +2,4 @@
/target
/docs/superpowers/
/media
/media

7
Cargo.lock generated
View File

@@ -265,6 +265,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"utoipa",
"uuid",
]
@@ -1050,6 +1051,7 @@ dependencies = [
"futures",
"hex",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
@@ -2013,8 +2015,8 @@ dependencies = [
[[package]]
name = "k-ap"
version = "0.1.0"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.3#7901b29f7c09415e82f7f098f89c1df6b86bbfd3"
version = "0.1.7"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.7#699258f830922830df956db8e5dea739ee1642aa"
dependencies = [
"activitypub_federation",
"anyhow",
@@ -2563,6 +2565,7 @@ dependencies = [
"chrono",
"domain",
"postgres",
"serde_json",
"sqlx",
"tokio",
"uuid",

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[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 }
url = { workspace = true }
serde = { workspace = true }

View File

@@ -14,36 +14,6 @@ use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
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 {
repo: Arc<dyn ActivityPubRepository>,
urls: ThoughtsUrls,
@@ -148,8 +118,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
actor_url: &Url,
object: serde_json::Value,
) -> Result<()> {
let note_extensions = extract_note_extensions(&object);
let note: ThoughtNote = serde_json::from_value(object)?;
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
return Ok(());
};
let author_id = self
.repo
.intern_remote_actor(actor_url.as_str())
@@ -249,7 +221,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
_actor_url: &Url,
object: serde_json::Value,
) -> 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
.apply_note_update(ap_id.as_str(), &note.content)
.await
@@ -440,46 +415,3 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.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());
}
}

View File

@@ -4,6 +4,37 @@ use k_ap::AS_PUBLIC;
use serde::{Deserialize, Serialize};
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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -42,6 +73,17 @@ pub struct ThoughtNoteInput {
}
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 {
Self {
kind: Default::default(),

View File

@@ -1,5 +1,55 @@
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]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(super::ThoughtNoteInput {

View File

@@ -502,72 +502,29 @@ impl FederationSchedulerPort for ApFederationAdapter {
#[async_trait]
impl FederationLookupPort for ApFederationAdapter {
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.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()
let actor = self
.inner
.lookup_actor_by_handle(handle)
.await
.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 {
url: ap_url.clone(),
handle: full_handle,
display_name: actor_json["name"].as_str().map(|s| s.to_string()),
avatar_url: actor_json["icon"]["url"].as_str().map(|s| s.to_string()),
outbox_url: actor_json["outbox"].as_str().map(|s| s.to_string()),
url: actor.ap_url.to_string(),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
last_fetched_at: chrono::Utc::now(),
bio: actor_json["summary"].as_str().map(|s| s.to_string()),
banner_url: actor_json["image"]["url"].as_str().map(|s| s.to_string()),
also_known_as: None,
followers_url: actor_json["followers"].as_str().map(|s| s.to_string()),
following_url: actor_json["following"].as_str().map(|s| s.to_string()),
attachment: vec![],
bio: actor.bio,
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as,
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
attachment: actor
.attachment
.into_iter()
.map(|f| (f.name, f.value))
.collect(),
})
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[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 }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -41,7 +41,10 @@ impl FederationEventService {
{
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? {
Some(u) => u,
@@ -58,6 +61,7 @@ impl FederationEventService {
} else {
None
};
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Create(Note)");
self.ap
.broadcast_create(
user_id,
@@ -72,8 +76,7 @@ impl FederationEventService {
thought_id,
user_id,
} => {
// No DB lookup — thought is already deleted when this event fires.
// No locality guard: delete commands only reach local thoughts via the use case.
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Delete");
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
self.ap.broadcast_delete(user_id, &ap_id).await
}
@@ -106,6 +109,7 @@ impl FederationEventService {
} else {
None
};
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Update(Note)");
self.ap
.broadcast_update(
user_id,
@@ -121,16 +125,19 @@ impl FederationEventService {
user_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? {
Some(u) if u.local => u,
_ => return Ok(()),
_ => {
tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)");
return Ok(());
}
};
let _ = booster;
if self.thoughts.find_by_id(thought_id).await?.is_none() {
return Ok(());
}
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
}
@@ -142,6 +149,7 @@ impl FederationEventService {
return Ok(());
}
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
.broadcast_undo_announce(user_id, &object_ap_id)
.await
@@ -152,10 +160,12 @@ impl FederationEventService {
user_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? {
Some(u) if u.local => u,
_ => return Ok(()),
_ => {
tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)");
return Ok(());
}
};
let _ = liker;
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? {
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? {
Some(u) => u,
None => return Ok(()),
};
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Like");
self.ap
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await
@@ -196,12 +210,14 @@ impl FederationEventService {
Some(u) => u,
None => return Ok(()),
};
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Undo(Like)");
self.ap
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await
}
DomainEvent::ProfileUpdated { user_id } => {
tracing::info!(user_id = %user_id, "federation: broadcasting actor update");
self.ap.broadcast_actor_update(user_id).await
}

View File

@@ -35,6 +35,7 @@ impl NotificationEventService {
if is_self_action(&thought.user_id, user_id) {
return Ok(());
}
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Like");
self.notifications
.save(&Notification {
id: NotificationId::new(),
@@ -60,6 +61,7 @@ impl NotificationEventService {
if is_self_action(&thought.user_id, user_id) {
return Ok(());
}
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Boost");
self.notifications
.save(&Notification {
id: NotificationId::new(),
@@ -77,6 +79,7 @@ impl NotificationEventService {
follower_id,
following_id,
} => {
tracing::info!(from = %follower_id, to = %following_id, "notification: Follow");
self.notifications
.save(&Notification {
id: NotificationId::new(),
@@ -105,6 +108,7 @@ impl NotificationEventService {
if is_self_action(&original.user_id, user_id) {
return Ok(());
}
tracing::info!(from = %user_id, to = %original.user_id, thought_id = %thought_id, "notification: Reply");
self.notifications
.save(&Notification {
id: NotificationId::new(),
@@ -123,6 +127,7 @@ impl NotificationEventService {
mentioned_user_id,
author_user_id,
} => {
tracing::info!(from = %author_user_id, to = %mentioned_user_id, thought_id = %thought_id, "notification: Mention");
self.notifications
.save(&Notification {
id: NotificationId::new(),

View File

@@ -14,7 +14,7 @@ postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { 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 }
event-transport = { workspace = true }
auth = { workspace = true }

View File

@@ -13,7 +13,7 @@ application = { workspace = true }
nats = { workspace = true }
event-transport = { 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 }
postgres = { workspace = true }
postgres-federation = { workspace = true }

View 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&apos;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>
);
}

View File

@@ -59,6 +59,8 @@ import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PendingRequests } from "@/components/federation/pending-requests";
import { CopyButton } from "@/components/copy-button";
import { HelpCircle } from "lucide-react";
interface ProfilePageProps {
params: Promise<{ username: string }>;
@@ -197,9 +199,19 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
@{user.username}
</p>
{fediverseHandle && (
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all break-all">
{fediverseHandle}
</p>
<div className="flex items-center gap-1 mt-0.5">
<p className="text-xs text-muted-foreground/70 font-mono break-all">
{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>

View 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>
);
}

View File

@@ -18,6 +18,15 @@ export function MainNav() {
>
Discover
</Link>
<Link
href="/about/fediverse"
className={cn(
"transition-colors hover:text-foreground/80",
pathname === "/about/fediverse" ? "text-foreground" : "text-foreground/60"
)}
>
Fediverse
</Link>
<SearchInput />
</nav>
);

View File

@@ -1,4 +1,9 @@
import Link from "next/link";
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 {
try {
@@ -40,6 +45,7 @@ function StarRating({ rating, max = 5 }: { rating: number; max?: number }) {
export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
const isWatchlist = meta.watchlistEntry === true;
const year = meta.releaseYear ? ` (${meta.releaseYear})` : "";
const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true });
const watchedDate = meta.watchedAt
? new Date(meta.watchedAt).toLocaleDateString(undefined, {
year: "numeric",
@@ -49,57 +55,74 @@ export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
: null;
return (
<div className="rounded-lg border bg-card overflow-hidden">
<div className="flex gap-3 p-3">
{/* Poster */}
<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>
)}
<Card className="transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
<CardHeader className="flex flex-row items-center space-y-0 pb-3">
<Link
href={profileHref(author.username, author.local)}
className="flex items-center gap-3 hover:opacity-80"
>
<UserAvatar src={author.avatarUrl} alt={author.displayName ?? author.username} />
<div className="flex flex-col min-w-0">
<span className="font-bold truncate">{author.displayName ?? author.username}</span>
{!author.local && (
<span className="text-xs text-muted-foreground/70 truncate">
{author.username.startsWith("@") ? author.username : `@${author.username}`}
</span>
)}
<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>
{/* Info */}
<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>
</CardContent>
</Card>
);
}

View File

@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/user-avatar";
import { toast } from "sonner";
import { UserPlus } from "lucide-react";
import { profileHref } from "@/lib/utils";
interface RemoteUserCardProps {
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) {
const [followed, setFollowed] = useState(false);
const [loading, setLoading] = useState(false);
@@ -56,7 +44,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
return (
<div className="flex items-center justify-between p-4 border rounded-lg">
<Link
href={resolveProfileHref(actor.handle)}
href={profileHref(actor.handle, false)}
className="flex items-center gap-3 hover:opacity-80"
>
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />

View File

@@ -155,7 +155,7 @@ export function RemoteUserProfile({
<TabsContent value="followers" className="mt-4">
<Connections
handle={actor.handle}
handle={handle}
token={token}
type="followers"
active={followersActive}
@@ -164,7 +164,7 @@ export function RemoteUserProfile({
<TabsContent value="following" className="mt-4">
<Connections
handle={actor.handle}
handle={handle}
token={token}
type="following"
active={followingActive}

View File

@@ -39,7 +39,7 @@ import {
import { ThoughtForm } from "@/components/thought-form";
import { MovieCard } from "@/components/movie-card";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { cn, profileHref } from "@/lib/utils";
interface ThoughtCardProps {
thought: Thought;
@@ -153,7 +153,7 @@ export function ThoughtCard({
>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link
href={`/users/${author.username}`}
href={profileHref(author.username, author.local)}
className="flex items-center gap-4 text-shadow-md"
>
<UserAvatar
@@ -164,6 +164,11 @@ export function ThoughtCard({
<span className="font-bold">
{author.displayName || author.username}
</span>
{!author.local && (
<span className="text-xs text-muted-foreground/70 truncate">
{author.username.startsWith("@") ? author.username : `@${author.username}`}
</span>
)}
<time
dateTime={new Date(thought.createdAt).toISOString()}
title={format(new Date(thought.createdAt), "PPP p")}

View File

@@ -1,3 +1,4 @@
import { cache } from "react";
import { z } from "zod";
export const UserSchema = z.object({
@@ -278,13 +279,14 @@ export const markAllNotificationsRead = (token: string) =>
token
);
export const lookupRemoteActor = (handle: string, token: string | null) =>
export const lookupRemoteActor = cache((handle: string, token: string | null) =>
apiFetch(
`/users/lookup?handle=${encodeURIComponent(handle)}`,
{ next: { tags: [`remote-actor:${handle}`] } },
RemoteActorSchema,
token
);
)
);
export const getRemoteActorPosts = (
handle: string,

View File

@@ -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[] {
const thoughtMap = new Map<string, Thought>();
thoughts.forEach((t) => thoughtMap.set(t.id, t));