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({

Your Feed

- +
{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.map((f, i) => ( +
+ update(i, "name", e.target.value)} + placeholder="Mood name" + className="max-w-[10rem] text-sm" + /> + update(i, "value", e.target.value)} + placeholder="Emoji" + className="max-w-[5rem] text-sm" + /> + +
+ ))} +
+ +
+ {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 && ( + ( + + )} + /> + )} ( - field.onChange(v === "__none__" ? undefined : v)} value={field.value ?? "__none__"}> - - + + - -
Public
-
- -
Followers
-
- -
Unlisted
-
- -
Direct
-
+ No mood + {allMoods.map((mood) => ( + {mood} + ))}
)} /> - )} - {replyToId && ( - - )} + {replyToId && ( + + )} +