From 442a61bbdbc4c67732e26f826688e86be3f8dd39 Mon Sep 17 00:00:00 2001
From: Gabriel Kaszewski
Date: Fri, 29 May 2026 15:38:35 +0200
Subject: [PATCH] feat: add optional mood to thoughts with custom moods support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mood is an optional label+emoji string (e.g. "relaxed π") on thoughts.
Users can define up to 8 custom moods in profile settings.
Mood federates via AP Note JSON and displays on thought cards.
---
crates/adapters/activitypub/src/service.rs | 10 ++
crates/adapters/postgres-search/src/lib.rs | 6 +-
crates/adapters/postgres-search/src/tests.rs | 1 +
.../021_thought_mood_and_custom_moods.sql | 2 +
.../adapters/postgres/src/activitypub/mod.rs | 1 +
crates/adapters/postgres/src/feed/mod.rs | 6 +-
crates/adapters/postgres/src/feed/tests.rs | 1 +
crates/adapters/postgres/src/follow/mod.rs | 6 +-
crates/adapters/postgres/src/tag/tests.rs | 1 +
crates/adapters/postgres/src/test_helpers.rs | 1 +
crates/adapters/postgres/src/thought/mod.rs | 13 ++-
crates/adapters/postgres/src/thought/tests.rs | 5 +
.../adapters/postgres/src/top_friend/mod.rs | 2 +-
crates/adapters/postgres/src/user/mod.rs | 17 +++-
crates/api-types/src/requests.rs | 2 +
crates/api-types/src/responses.rs | 3 +
.../src/services/federation_event/tests.rs | 3 +
.../src/services/notification_event/tests.rs | 5 +
.../application/src/use_cases/profile/mod.rs | 17 ++++
.../application/src/use_cases/social/tests.rs | 1 +
.../application/src/use_cases/thoughts/mod.rs | 7 ++
.../src/use_cases/thoughts/tests.rs | 4 +
crates/domain/src/models/thought.rs | 3 +
crates/domain/src/models/user.rs | 4 +
crates/presentation/src/handlers/auth.rs | 9 ++
crates/presentation/src/handlers/feed.rs | 1 +
crates/presentation/src/handlers/thoughts.rs | 1 +
crates/presentation/src/handlers/users/mod.rs | 3 +
thoughts-frontend/app/page.tsx | 2 +-
.../app/settings/profile/page.tsx | 2 +
.../components/custom-moods-editor.tsx | 99 +++++++++++++++++++
thoughts-frontend/components/thought-card.tsx | 6 ++
thoughts-frontend/components/thought-form.tsx | 86 +++++++++++-----
thoughts-frontend/lib/api.ts | 6 ++
34 files changed, 294 insertions(+), 42 deletions(-)
create mode 100644 crates/adapters/postgres/migrations/021_thought_mood_and_custom_moods.sql
create mode 100644 thoughts-frontend/components/custom-moods-editor.tsx
diff --git a/crates/adapters/activitypub/src/service.rs b/crates/adapters/activitypub/src/service.rs
index 2cea3bf..8bc5521 100644
--- a/crates/adapters/activitypub/src/service.rs
+++ b/crates/adapters/activitypub/src/service.rs
@@ -95,6 +95,16 @@ fn build_note_json(
.collect();
note["tag"] = serde_json::json!(ap_tags);
}
+ if let Some(ref mood) = thought.mood {
+ note["mood"] = serde_json::json!(mood);
+ }
+ if let Some(ref ext) = thought.note_extensions {
+ if let Some(obj) = ext.as_object() {
+ for (k, v) in obj {
+ note.as_object_mut().unwrap().entry(k).or_insert(v.clone());
+ }
+ }
+ }
note
}
diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs
index 7f0ed95..e0fcea6 100644
--- a/crates/adapters/postgres-search/src/lib.rs
+++ b/crates/adapters/postgres-search/src/lib.rs
@@ -36,6 +36,7 @@ struct FeedRow {
thought_created_at: DateTime,
thought_updated_at: Option>,
note_extensions: Option,
+ mood: Option,
#[sqlx(flatten)]
author: postgres::user::UserRow,
like_count: i64,
@@ -58,9 +59,9 @@ fn feed_select(viewer: Option) -> String {
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
t.in_reply_to_id,\n\
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
- t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions,\n\
+ t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\
u.id, u.username, u.email, u.password_hash,\n\
- u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields,\n\
+ u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
u.local,\n\
u.created_at, u.updated_at,\n\
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
@@ -84,6 +85,7 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result (Us
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
trepo.save(&t).await.unwrap();
(u, t)
diff --git a/crates/adapters/postgres/migrations/021_thought_mood_and_custom_moods.sql b/crates/adapters/postgres/migrations/021_thought_mood_and_custom_moods.sql
new file mode 100644
index 0000000..4cf11fe
--- /dev/null
+++ b/crates/adapters/postgres/migrations/021_thought_mood_and_custom_moods.sql
@@ -0,0 +1,2 @@
+ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
+ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;
diff --git a/crates/adapters/postgres/src/activitypub/mod.rs b/crates/adapters/postgres/src/activitypub/mod.rs
index c783b69..cbb7fe8 100644
--- a/crates/adapters/postgres/src/activitypub/mod.rs
+++ b/crates/adapters/postgres/src/activitypub/mod.rs
@@ -41,6 +41,7 @@ impl OutboxRow {
created_at: self.created_at,
updated_at: self.updated_at,
note_extensions: None,
+ mood: None,
},
author_username: Username::from_trusted(self.username),
}
diff --git a/crates/adapters/postgres/src/feed/mod.rs b/crates/adapters/postgres/src/feed/mod.rs
index ffb8533..bafd2b7 100644
--- a/crates/adapters/postgres/src/feed/mod.rs
+++ b/crates/adapters/postgres/src/feed/mod.rs
@@ -36,6 +36,7 @@ struct FeedRow {
thought_created_at: DateTime,
thought_updated_at: Option>,
note_extensions: Option,
+ mood: Option,
#[sqlx(flatten)]
author: crate::user::UserRow,
like_count: i64,
@@ -58,6 +59,7 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result FeedSqlBuilder<'a> {
t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
- t.note_extensions,
+ t.note_extensions, t.mood,
u.id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
@@ -124,7 +126,7 @@ impl<'a> FeedSqlBuilder<'a> {
COALESCE(ra.display_name, u.display_name) AS display_name,
u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
- u.header_url, u.custom_css, u.profile_fields,
+ u.header_url, u.custom_css, u.profile_fields, u.custom_moods,
u.local,
u.created_at, u.updated_at,
COALESCE(l_agg.cnt, 0) AS like_count,
diff --git a/crates/adapters/postgres/src/feed/tests.rs b/crates/adapters/postgres/src/feed/tests.rs
index 9639d3d..d231929 100644
--- a/crates/adapters/postgres/src/feed/tests.rs
+++ b/crates/adapters/postgres/src/feed/tests.rs
@@ -28,6 +28,7 @@ async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thou
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
trepo.save(&t).await.unwrap();
(u, t)
diff --git a/crates/adapters/postgres/src/follow/mod.rs b/crates/adapters/postgres/src/follow/mod.rs
index 62ad735..aebec0f 100644
--- a/crates/adapters/postgres/src/follow/mod.rs
+++ b/crates/adapters/postgres/src/follow/mod.rs
@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>(
- "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
+ "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.follower_id=u.id
WHERE f.following_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>(
- "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
+ "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.following_id=u.id
WHERE f.follower_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
@@ -210,7 +210,7 @@ impl FollowRepository for PgFollowRepository {
let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
- u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
+ u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at
FROM users u
JOIN follows f1
diff --git a/crates/adapters/postgres/src/tag/tests.rs b/crates/adapters/postgres/src/tag/tests.rs
index 5d6a205..bcd80ea 100644
--- a/crates/adapters/postgres/src/tag/tests.rs
+++ b/crates/adapters/postgres/src/tag/tests.rs
@@ -37,6 +37,7 @@ async fn attach_and_list(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool);
diff --git a/crates/adapters/postgres/src/test_helpers.rs b/crates/adapters/postgres/src/test_helpers.rs
index bb64233..1cb28ff 100644
--- a/crates/adapters/postgres/src/test_helpers.rs
+++ b/crates/adapters/postgres/src/test_helpers.rs
@@ -31,6 +31,7 @@ pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
trepo.save(&t).await.unwrap();
(user, t)
diff --git a/crates/adapters/postgres/src/thought/mod.rs b/crates/adapters/postgres/src/thought/mod.rs
index 188f5b9..cf14583 100644
--- a/crates/adapters/postgres/src/thought/mod.rs
+++ b/crates/adapters/postgres/src/thought/mod.rs
@@ -35,6 +35,7 @@ pub(crate) struct ThoughtRow {
pub created_at: DateTime,
pub updated_at: Option>,
pub note_extensions: Option,
+ pub mood: Option,
}
impl TryFrom for Thought {
@@ -52,19 +53,20 @@ impl TryFrom for Thought {
created_at: r.created_at,
updated_at: r.updated_at,
note_extensions: r.note_extensions,
+ mood: r.mood,
})
}
}
const THOUGHT_SELECT: &str =
- "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts";
+ "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood FROM thoughts";
#[async_trait]
impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query(
- "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
- VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,mood)
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
)
.bind(t.id.as_uuid())
@@ -76,6 +78,7 @@ impl ThoughtRepository for PgThoughtRepository {
.bind(t.sensitive)
.bind(t.local)
.bind(t.created_at)
+ .bind(&t.mood)
.execute(&self.pool)
.await
.into_domain()
@@ -119,11 +122,11 @@ impl ThoughtRepository for PgThoughtRepository {
sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id,
- visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions
+ visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood
FROM thoughts WHERE id = $1
UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
- t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions
+ t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions,t.mood
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
)
SELECT * FROM thread ORDER BY created_at ASC",
diff --git a/crates/adapters/postgres/src/thought/tests.rs b/crates/adapters/postgres/src/thought/tests.rs
index 6c62e64..75cd328 100644
--- a/crates/adapters/postgres/src/thought/tests.rs
+++ b/crates/adapters/postgres/src/thought/tests.rs
@@ -14,6 +14,7 @@ async fn save_and_find_thought(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
@@ -33,6 +34,7 @@ async fn delete_thought(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap();
@@ -52,6 +54,7 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
@@ -70,6 +73,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
let reply = Thought::new_local(NewThought {
id: ThoughtId::new(),
@@ -79,6 +83,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
diff --git a/crates/adapters/postgres/src/top_friend/mod.rs b/crates/adapters/postgres/src/top_friend/mod.rs
index 062fa03..a693416 100644
--- a/crates/adapters/postgres/src/top_friend/mod.rs
+++ b/crates/adapters/postgres/src/top_friend/mod.rs
@@ -54,7 +54,7 @@ impl TopFriendRepository for PgTopFriendRepository {
let rows = sqlx::query_as::<_, TopFriendRow>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
- u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
+ u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position",
diff --git a/crates/adapters/postgres/src/user/mod.rs b/crates/adapters/postgres/src/user/mod.rs
index 5757a8a..5e87ff2 100644
--- a/crates/adapters/postgres/src/user/mod.rs
+++ b/crates/adapters/postgres/src/user/mod.rs
@@ -32,6 +32,7 @@ pub struct UserRow {
pub header_url: Option,
pub custom_css: Option,
pub profile_fields: Option,
+ pub custom_moods: Option,
pub local: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
@@ -50,6 +51,7 @@ impl From for User {
header_url: r.header_url,
custom_css: r.custom_css,
profile_fields: crate::jsonb::parse_name_value(r.profile_fields),
+ custom_moods: crate::jsonb::parse_name_value(r.custom_moods),
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
@@ -59,7 +61,7 @@ impl From for User {
pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
- custom_css,profile_fields,local,created_at,updated_at FROM users";
+ custom_css,profile_fields,custom_moods,local,created_at,updated_at FROM users";
#[async_trait]
impl UserReader for PgUserRepository {
@@ -225,15 +227,17 @@ impl UserReader for PgUserRepository {
impl UserWriter for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> {
let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields);
+ let custom_moods_json = crate::jsonb::serialize_name_value(&user.custom_moods);
sqlx::query(
- "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
+ "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,custom_moods,local,created_at,updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
profile_fields=EXCLUDED.profile_fields,
+ custom_moods=EXCLUDED.custom_moods,
local=EXCLUDED.local,
updated_at=NOW()"
)
@@ -247,6 +251,7 @@ impl UserWriter for PgUserRepository {
.bind(&user.header_url)
.bind(&user.custom_css)
.bind(&profile_fields_json)
+ .bind(&custom_moods_json)
.bind(user.local)
.bind(user.created_at)
.bind(user.updated_at)
@@ -276,6 +281,10 @@ impl UserWriter for PgUserRepository {
.profile_fields
.as_ref()
.map(|f| crate::jsonb::serialize_name_value(f));
+ let custom_moods_json: Option = input
+ .custom_moods
+ .as_ref()
+ .map(|f| crate::jsonb::serialize_name_value(f));
sqlx::query(
"UPDATE users SET \
display_name = COALESCE($2, display_name), \
@@ -284,6 +293,7 @@ impl UserWriter for PgUserRepository {
header_url = COALESCE($5, header_url), \
custom_css = COALESCE($6, custom_css), \
profile_fields = COALESCE($7, profile_fields), \
+ custom_moods = COALESCE($8, custom_moods), \
updated_at = NOW() \
WHERE id = $1",
)
@@ -294,6 +304,7 @@ impl UserWriter for PgUserRepository {
.bind(input.header_url)
.bind(input.custom_css)
.bind(profile_fields_json)
+ .bind(custom_moods_json)
.execute(&self.pool)
.await
.into_domain()
diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs
index 895e6d7..a991171 100644
--- a/crates/api-types/src/requests.rs
+++ b/crates/api-types/src/requests.rs
@@ -31,6 +31,7 @@ pub struct CreateThoughtRequest {
pub visibility: Option,
pub content_warning: Option,
pub sensitive: Option,
+ pub mood: Option,
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -48,6 +49,7 @@ pub struct UpdateProfileRequest {
pub header_url: Option,
pub custom_css: Option,
pub profile_fields: Option>,
+ pub custom_moods: Option>,
}
#[derive(Deserialize, utoipa::ToSchema)]
diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs
index fb81fdf..bfa3fb9 100644
--- a/crates/api-types/src/responses.rs
+++ b/crates/api-types/src/responses.rs
@@ -20,6 +20,7 @@ pub struct UserResponse {
pub header_url: Option,
pub custom_css: Option,
pub profile_fields: Vec,
+ pub custom_moods: Vec,
pub local: bool,
pub is_followed_by_viewer: bool,
#[serde(rename = "joinedAt")]
@@ -48,6 +49,8 @@ pub struct ThoughtResponse {
pub updated_at: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_extensions: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub mood: Option,
}
#[derive(Serialize, utoipa::ToSchema)]
diff --git a/crates/application/src/services/federation_event/tests.rs b/crates/application/src/services/federation_event/tests.rs
index abcfbc2..9f184b9 100644
--- a/crates/application/src/services/federation_event/tests.rs
+++ b/crates/application/src/services/federation_event/tests.rs
@@ -100,6 +100,7 @@ fn local_thought(author_id: UserId) -> Thought {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
})
}
@@ -283,6 +284,7 @@ async fn direct_thought_created_does_not_broadcast() {
visibility: Visibility::Direct,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone());
@@ -312,6 +314,7 @@ async fn followers_only_thought_does_not_broadcast_publicly() {
visibility: Visibility::Followers,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone());
diff --git a/crates/application/src/services/notification_event/tests.rs b/crates/application/src/services/notification_event/tests.rs
index 7435f97..f3d12b3 100644
--- a/crates/application/src/services/notification_event/tests.rs
+++ b/crates/application/src/services/notification_event/tests.rs
@@ -32,6 +32,7 @@ async fn like_creates_notification_for_thought_author() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
@@ -62,6 +63,7 @@ async fn self_like_creates_no_notification() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
@@ -111,6 +113,7 @@ async fn reply_creates_notification_for_original_author() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService {
@@ -141,6 +144,7 @@ async fn self_reply_creates_no_notification() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService {
@@ -169,6 +173,7 @@ async fn self_boost_creates_no_notification() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService {
diff --git a/crates/application/src/use_cases/profile/mod.rs b/crates/application/src/use_cases/profile/mod.rs
index 6b92410..16e3732 100644
--- a/crates/application/src/use_cases/profile/mod.rs
+++ b/crates/application/src/use_cases/profile/mod.rs
@@ -2,6 +2,9 @@ const MAX_TOP_FRIENDS: usize = 8;
const MAX_PROFILE_FIELDS: usize = 4;
const MAX_FIELD_NAME_LEN: usize = 64;
const MAX_FIELD_VALUE_LEN: usize = 256;
+const MAX_CUSTOM_MOODS: usize = 8;
+const MAX_MOOD_LABEL_LEN: usize = 32;
+const MAX_MOOD_EMOJI_LEN: usize = 8;
use bytes::Bytes;
use domain::{
@@ -72,6 +75,20 @@ pub async fn update_profile(
}
}
}
+ if let Some(ref moods) = input.custom_moods {
+ if moods.len() > MAX_CUSTOM_MOODS {
+ return Err(DomainError::InvalidInput(format!(
+ "custom moods: max {MAX_CUSTOM_MOODS}"
+ )));
+ }
+ for (label, emoji) in moods {
+ if label.len() > MAX_MOOD_LABEL_LEN || emoji.len() > MAX_MOOD_EMOJI_LEN {
+ return Err(DomainError::InvalidInput(
+ "custom mood label or emoji too long".into(),
+ ));
+ }
+ }
+ }
users.update_profile(user_id, input).await?;
events
.publish(&DomainEvent::ProfileUpdated {
diff --git a/crates/application/src/use_cases/social/tests.rs b/crates/application/src/use_cases/social/tests.rs
index a5561b2..5bb523e 100644
--- a/crates/application/src/use_cases/social/tests.rs
+++ b/crates/application/src/use_cases/social/tests.rs
@@ -34,6 +34,7 @@ async fn like_and_unlike() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
}));
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1);
diff --git a/crates/application/src/use_cases/thoughts/mod.rs b/crates/application/src/use_cases/thoughts/mod.rs
index b7a3220..b27ffe0 100644
--- a/crates/application/src/use_cases/thoughts/mod.rs
+++ b/crates/application/src/use_cases/thoughts/mod.rs
@@ -26,6 +26,7 @@ pub struct CreateThoughtInput {
pub visibility: Option,
pub content_warning: Option,
pub sensitive: bool,
+ pub mood: Option,
}
pub struct CreateThoughtOutput {
pub thought: Thought,
@@ -39,6 +40,11 @@ pub async fn create_thought(
outbox: &dyn OutboxWriter,
input: CreateThoughtInput,
) -> Result {
+ if let Some(ref m) = input.mood {
+ if m.len() > 64 {
+ return Err(DomainError::InvalidInput("mood: max 64 chars".into()));
+ }
+ }
let content = Content::new_local(input.content)?;
let visibility = match input.visibility.as_deref() {
Some("followers") => Visibility::Followers,
@@ -54,6 +60,7 @@ pub async fn create_thought(
visibility,
content_warning: input.content_warning,
sensitive: input.sensitive,
+ mood: input.mood,
});
thoughts.save(&thought).await?;
diff --git a/crates/application/src/use_cases/thoughts/tests.rs b/crates/application/src/use_cases/thoughts/tests.rs
index ea1b091..84c4cbe 100644
--- a/crates/application/src/use_cases/thoughts/tests.rs
+++ b/crates/application/src/use_cases/thoughts/tests.rs
@@ -22,6 +22,7 @@ fn input(uid: UserId) -> CreateThoughtInput {
visibility: None,
content_warning: None,
sensitive: false,
+ mood: None,
}
}
@@ -207,6 +208,7 @@ async fn create_reply_sets_in_reply_to_id() {
visibility: None,
content_warning: None,
sensitive: false,
+ mood: None,
},
)
.await
@@ -243,6 +245,7 @@ fn make_thought(user_id: UserId) -> Thought {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
})
}
@@ -295,6 +298,7 @@ async fn get_thread_views_batches_correctly() {
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
+ mood: None,
});
::save(&store, &reply)
.await
diff --git a/crates/domain/src/models/thought.rs b/crates/domain/src/models/thought.rs
index d6a8e9b..038c5b5 100644
--- a/crates/domain/src/models/thought.rs
+++ b/crates/domain/src/models/thought.rs
@@ -22,6 +22,7 @@ pub struct Thought {
pub created_at: DateTime,
pub updated_at: Option>,
pub note_extensions: Option,
+ pub mood: Option,
}
impl Visibility {
@@ -55,6 +56,7 @@ pub struct NewThought {
pub visibility: Visibility,
pub content_warning: Option,
pub sensitive: bool,
+ pub mood: Option,
}
impl Thought {
@@ -71,6 +73,7 @@ impl Thought {
created_at: Utc::now(),
updated_at: None,
note_extensions: None,
+ mood: p.mood,
}
}
}
diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs
index 2d179a6..5ec364e 100644
--- a/crates/domain/src/models/user.rs
+++ b/crates/domain/src/models/user.rs
@@ -9,6 +9,7 @@ pub struct UpdateProfileInput {
pub header_url: Option,
pub custom_css: Option,
pub profile_fields: Option>,
+ pub custom_moods: Option>,
}
#[derive(Debug, Clone)]
@@ -23,6 +24,7 @@ pub struct User {
pub header_url: Option,
pub custom_css: Option,
pub profile_fields: Vec<(String, String)>,
+ pub custom_moods: Vec<(String, String)>,
pub local: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
@@ -47,6 +49,7 @@ impl User {
header_url: None,
custom_css: None,
profile_fields: vec![],
+ custom_moods: vec![],
local: true,
created_at: now,
updated_at: now,
@@ -66,6 +69,7 @@ impl User {
header_url: None,
custom_css: None,
profile_fields: vec![],
+ custom_moods: vec![],
local: false,
created_at: now,
updated_at: now,
diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs
index c4ea8ad..192dfa9 100644
--- a/crates/presentation/src/handlers/auth.rs
+++ b/crates/presentation/src/handlers/auth.rs
@@ -32,6 +32,14 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
value: v.clone(),
})
.collect(),
+ custom_moods: u
+ .custom_moods
+ .iter()
+ .map(|(n, v)| ProfileField {
+ name: n.clone(),
+ value: v.clone(),
+ })
+ .collect(),
local: u.local,
is_followed_by_viewer: false,
created_at: u.created_at,
@@ -48,6 +56,7 @@ pub fn to_summary_response(u: &UserSummary) -> UserResponse {
header_url: None,
custom_css: None,
profile_fields: vec![],
+ custom_moods: vec![],
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs
index afd53e3..47b58f5 100644
--- a/crates/presentation/src/handlers/feed.rs
+++ b/crates/presentation/src/handlers/feed.rs
@@ -109,6 +109,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
created_at: e.thought.created_at,
updated_at: e.thought.updated_at,
note_extensions: e.thought.note_extensions.clone(),
+ mood: e.thought.mood.clone(),
}
}
diff --git a/crates/presentation/src/handlers/thoughts.rs b/crates/presentation/src/handlers/thoughts.rs
index 8b8d19d..301cbf5 100644
--- a/crates/presentation/src/handlers/thoughts.rs
+++ b/crates/presentation/src/handlers/thoughts.rs
@@ -61,6 +61,7 @@ pub async fn post_thought(
visibility: body.visibility,
content_warning: body.content_warning,
sensitive: body.sensitive.unwrap_or(false),
+ mood: body.mood,
},
)
.await?;
diff --git a/crates/presentation/src/handlers/users/mod.rs b/crates/presentation/src/handlers/users/mod.rs
index 10973df..1f95b6b 100644
--- a/crates/presentation/src/handlers/users/mod.rs
+++ b/crates/presentation/src/handlers/users/mod.rs
@@ -117,6 +117,9 @@ pub async fn patch_profile(
profile_fields: body
.profile_fields
.map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()),
+ custom_moods: body
+ .custom_moods
+ .map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()),
},
)
.await?;
diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx
index 728787d..de9ebfe 100644
--- a/thoughts-frontend/app/page.tsx
+++ b/thoughts-frontend/app/page.tsx
@@ -114,7 +114,7 @@ async function FeedPage({
-
+
{sidebar}
diff --git a/thoughts-frontend/app/settings/profile/page.tsx b/thoughts-frontend/app/settings/profile/page.tsx
index 5510178..03d692c 100644
--- a/thoughts-frontend/app/settings/profile/page.tsx
+++ b/thoughts-frontend/app/settings/profile/page.tsx
@@ -9,6 +9,7 @@ export const metadata: Metadata = {
import { redirect } from "next/navigation";
import { getMe } from "@/lib/api";
import { EditProfileForm } from "@/components/edit-profile-form";
+import { CustomMoodsEditor } from "@/components/custom-moods-editor";
export default async function EditProfilePage() {
const token = (await cookies()).get("auth_token")?.value;
@@ -32,6 +33,7 @@ export default async function EditProfilePage() {
+
);
}
diff --git a/thoughts-frontend/components/custom-moods-editor.tsx b/thoughts-frontend/components/custom-moods-editor.tsx
new file mode 100644
index 0000000..4358fdc
--- /dev/null
+++ b/thoughts-frontend/components/custom-moods-editor.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { updateProfile, type ProfileField } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { toast } from "sonner";
+import { Plus, Trash2 } from "lucide-react";
+
+const MAX_MOODS = 8;
+
+export function CustomMoodsEditor({
+ initial,
+}: {
+ initial: ProfileField[];
+}) {
+ const { token } = useAuth();
+ const [moods, setMoods] = useState(initial);
+ const [saving, setSaving] = useState(false);
+
+ const update = (i: number, key: "name" | "value", val: string) => {
+ setMoods((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f)));
+ };
+
+ const add = () => {
+ if (moods.length >= MAX_MOODS) return;
+ setMoods((prev) => [...prev, { name: "", value: "" }]);
+ };
+
+ const remove = (i: number) => {
+ setMoods((prev) => prev.filter((_, j) => j !== i));
+ };
+
+ const save = async () => {
+ if (!token) return;
+ const clean = moods.filter((f) => f.name.trim() || f.value.trim());
+ setSaving(true);
+ try {
+ await updateProfile({ customMoods: clean }, token);
+ setMoods(clean);
+ toast.success("Custom moods saved.");
+ } catch {
+ toast.error("Failed to save custom moods.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
Custom moods
+
+ Add up to {MAX_MOODS} custom moods. These appear alongside the
+ predefined moods when composing a thought.
+
+
+
+
+
+
+ {moods.length < MAX_MOODS && (
+
+ )}
+
+
+
+ );
+}
diff --git a/thoughts-frontend/components/thought-card.tsx b/thoughts-frontend/components/thought-card.tsx
index 21791ad..4eb5974 100644
--- a/thoughts-frontend/components/thought-card.tsx
+++ b/thoughts-frontend/components/thought-card.tsx
@@ -223,6 +223,11 @@ export function ThoughtCard({
}}
/>
)}
+ {(thought.mood || (meta?.mood as string | undefined)) && (
+
+ feeling {thought.mood || (meta?.mood as string)}
+
+ )}
{token && (
@@ -244,6 +249,7 @@ export function ThoughtCard({
setIsReplyOpen(false)}
+ currentUser={currentUser}
/>
)}
diff --git a/thoughts-frontend/components/thought-form.tsx b/thoughts-frontend/components/thought-form.tsx
index 5d1dc6f..65c5afd 100644
--- a/thoughts-frontend/components/thought-form.tsx
+++ b/thoughts-frontend/components/thought-form.tsx
@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
-import { CreateThoughtSchema } from "@/lib/api"
+import { CreateThoughtSchema, type Me } from "@/lib/api"
import { useAuth } from "@/hooks/use-auth"
import { toast } from "sonner"
import { Globe, Lock, Users } from "lucide-react"
@@ -28,6 +28,13 @@ import { useState } from "react"
import { Confetti } from "./confetti"
import { createThought } from "@/app/actions/thoughts"
+const DEFAULT_MOODS = [
+ "relaxed π", "happy π", "excited π€©", "grateful π", "inspired β¨",
+ "thoughtful π€", "curious π§", "amused π", "proud πͺ", "hopeful π",
+ "tired π΄", "stressed π°", "anxious π", "sad π’", "frustrated π€",
+ "angry π ", "bored π", "confused π", "nostalgic π₯Ή", "silly π€ͺ",
+]
+
interface ThoughtFormProps {
/** Set to the parent thought ID when composing a reply. */
replyToId?: string
@@ -35,12 +42,20 @@ interface ThoughtFormProps {
onSuccess?: () => void
/** Whether to wrap in a Card. Defaults to true when no replyToId. */
card?: boolean
+ currentUser?: Me | null
}
-export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: ThoughtFormProps) {
+export function ThoughtForm({ replyToId, onSuccess, card = !replyToId, currentUser }: ThoughtFormProps) {
const { token } = useAuth()
const [showConfetti, setShowConfetti] = useState(false)
+ const allMoods = [
+ ...DEFAULT_MOODS,
+ ...(currentUser?.customMoods ?? [])
+ .filter(m => !DEFAULT_MOODS.some(d => d.toLowerCase().startsWith(m.name.toLowerCase())))
+ .map(m => `${m.name} ${m.value}`),
+ ]
+
const form = useForm>({
resolver: zodResolver(CreateThoughtSchema),
defaultValues: {
@@ -86,40 +101,61 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
)}
/>
- {!replyToId && (
+
+ {!replyToId && (
+
(
+
+ )}
+ />
+ )}
(
-