feat: add optional mood to thoughts with custom moods support
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:
@@ -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;
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ struct FeedRow {
|
||||
thought_created_at: DateTime<Utc>,
|
||||
thought_updated_at: Option<DateTime<Utc>>,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
mood: Option<String>,
|
||||
#[sqlx(flatten)]
|
||||
author: crate::user::UserRow,
|
||||
like_count: i64,
|
||||
@@ -58,6 +59,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.thought_updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
mood: r.mood,
|
||||
};
|
||||
let author = User::from(r.author);
|
||||
Ok(FeedEntry {
|
||||
@@ -112,7 +114,7 @@ impl<'a> 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,7 @@ pub(crate) struct ThoughtRow {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<ThoughtRow> for Thought {
|
||||
@@ -52,19 +53,20 @@ impl TryFrom<ThoughtRow> 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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct UserRow {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Option<serde_json::Value>,
|
||||
pub custom_moods: Option<serde_json::Value>,
|
||||
pub local: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -50,6 +51,7 @@ impl From<UserRow> 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<UserRow> 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<serde_json::Value> = 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()
|
||||
|
||||
Reference in New Issue
Block a user