feat: add optional mood to thoughts with custom moods support
Some checks failed
lint / lint (push) Failing after 9m28s
test / unit (push) Successful in 16m8s

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.
This commit is contained in:
2026-05-29 15:38:35 +02:00
parent be27fe04e2
commit 442a61bbdb
34 changed files with 294 additions and 42 deletions

View File

@@ -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());

View File

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

View File

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

View File

@@ -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);

View File

@@ -26,6 +26,7 @@ pub struct CreateThoughtInput {
pub visibility: Option<String>,
pub content_warning: Option<String>,
pub sensitive: bool,
pub mood: Option<String>,
}
pub struct CreateThoughtOutput {
pub thought: Thought,
@@ -39,6 +40,11 @@ pub async fn create_thought(
outbox: &dyn OutboxWriter,
input: CreateThoughtInput,
) -> Result<CreateThoughtOutput, DomainError> {
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?;

View File

@@ -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,
});
<TestStore as ThoughtRepository>::save(&store, &reply)
.await