diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 152cde1..1eafa54 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -38,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok(CustomEmoji::replace(value.as_str().unwrap()).into()) +} + fn color_escape(value: &Value, _: &HashMap) -> tera::Result { Ok(sanitize::color_escape(value.as_str().unwrap()).into()) } @@ -102,6 +106,7 @@ async fn main() { tera.register_filter("has_staff_badge", check_staff_badge); tera.register_filter("has_banned", check_banned); tera.register_filter("remove_script_tags", remove_script_tags); + tera.register_filter("emojis", render_emojis); let client = Client::new(); diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index a24ca27..0dc16c3 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -109,6 +109,23 @@ ("title" "Send") (text "{{ icon \"send-horizontal\" }}")))) (text "{%- endif %}") + + ; emoji picker + (text "{{ components::emoji_picker(element_id=\"\", render_dialog=true, render_button=false) }}") + (input ("id" "react_emoji_picker_field") ("class" "hidden") ("type" "hidden")) + + (script + (text "window.EMOJI_PICKER_MODE = \"replace\"; + document.getElementById(\"react_emoji_picker_field\").addEventListener(\"change\", (e) => { + if (!EMOJI_PICKER_REACTION_MESSAGE_ID) { + return; + } + + const emoji = e.target.value === \"::\" ? \":heart:\" : e.target.value; + trigger(\"me::message_react\", [document.getElementById(`message-${EMOJI_PICKER_REACTION_MESSAGE_ID}`), EMOJI_PICKER_REACTION_MESSAGE_ID, emoji]); + });")) + + ; ... (script (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\"); window.VIEWING_SINGLE = \"{{ message }}\".length > 0; @@ -434,6 +451,7 @@ const clean_text = () => { trigger(\"atto::clean_date_codes\"); trigger(\"atto::hooks::online_indicator\"); + trigger(\"atto::hooks::check_message_reactions\"); }; document.addEventListener( diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a9f6142..251359d 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1026,9 +1026,29 @@ (text "{%- endif %}") (div ("class" "flex w-full gap-2 justify-between") - (span - ("class" "no_p_margin") - (text "{{ message.content|markdown|safe }}")) + (div + ("class" "flex flex-col gap-2") + (span + ("class" "no_p_margin") + (text "{{ message.content|markdown|safe }}")) + + (div + ("class" "flex w-full gap-1 flex-wrap") + ("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'") + ("hook" "check_message_reactions") + ("hook-arg:id" "{{ message.id }}") + + (text "{% for emoji,num in message.reactions -%}") + (button + ("class" "small lowered") + ("ui_ident" "emoji_{{ emoji }}") + ("onclick" "trigger('me::message_react', [event.target.parentElement.parentElement, '{{ message.id }}', '{{ emoji }}'])") + (span (text "{{ emoji|emojis|safe }} {{ num }}"))) + (text "{%- endfor %}") + + (div + ("class" "hidden") + (text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}")))) (text "{% if grouped -%}") (div ("class" "hidden") @@ -1185,13 +1205,15 @@ (text "{{ text \"chats:action.kick_member\" }}"))))) (text "{%- endif %}")) -(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false) -%}") +(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false, render_button=true, small=false) -%}") +(text "{% if render_button -%}") (button - ("class" "button small square lowered") + ("class" "button small {% if not small -%} square {%- endif %} lowered") ("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()") ("title" "Emojis") ("type" "button") (text "{{ icon \"smile-plus\" }}")) +(text "{%- endif %}") (text "{% if render_dialog -%}") (dialog @@ -1237,20 +1259,41 @@ } if (event.detail.unicode) { - document.getElementById( - window.EMOJI_PICKER_TEXT_ID, - ).value += ` :${await ( - await fetch(\"/api/v1/lookup_emoji\", { - method: \"POST\", - body: event.detail.unicode, - }) - ).text()}:`; + if (window.EMOJI_PICKER_MODE === \"replace\") { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value = `:${await ( + await fetch(\"/api/v1/lookup_emoji\", { + method: \"POST\", + body: event.detail.unicode, + }) + ).text()}:`; + } else { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value += ` :${await ( + await fetch(\"/api/v1/lookup_emoji\", { + method: \"POST\", + body: event.detail.unicode, + }) + ).text()}:`; + } } else { - document.getElementById( - window.EMOJI_PICKER_TEXT_ID, - ).value += ` :${event.detail.emoji.shortcodes[0]}:`; + if (window.EMOJI_PICKER_MODE === \"replace\") { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value = `:${event.detail.emoji.shortcodes[0]}:`; + } else { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value += ` :${event.detail.emoji.shortcodes[0]}:`; + } } + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).dispatchEvent(new Event(\"change\")); + document.getElementById(\"emoji_dialog\").close(); });")) (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index a417a46..9c9d71d 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -688,6 +688,36 @@ media_theme_pref(); $.OBSERVERS.push(observer); }); + self.define("hooks::check_message_reactions", async ({ $ }) => { + const observer = $.offload_work_to_client_when_in_view( + async (element) => { + const reactions = await ( + await fetch( + `/api/v1/message_reactions/${element.getAttribute("hook-arg:id")}`, + ) + ).json(); + + if (reactions.ok) { + for (const reaction of reactions.payload) { + element + .querySelector( + `[ui_ident=emoji_${reaction.emoji.replaceAll(":", "\\:")}]`, + ) + .classList.remove("lowered"); + } + } + }, + ); + + for (const element of Array.from( + document.querySelectorAll("[hook=check_message_reactions]") || [], + )) { + observer.observe(element); + } + + $.OBSERVERS.push(observer); + }); + self.define("hooks::tabs:switch", (_, tab) => { tab = tab.split("?")[0]; diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index e8f4ae2..96ac95f 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -204,6 +204,47 @@ }); }); + self.define("message_react", async (_, element, message, emoji) => { + await trigger("atto::debounce", ["reactions::toggle"]); + fetch("/api/v1/message_reactions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message, + emoji, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + + if (res.ok) { + if (res.message.includes("created")) { + const x = element.querySelector( + `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`, + ); + + if (x) { + x.classList.remove("lowered"); + } + } else { + const x = element.querySelector( + `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`, + ); + + if (x) { + x.classList.add("lowered"); + } + } + } + }); + }); + self.define("remove_notification", (_, id) => { fetch(`/api/v1/notifications/${id}`, { method: "DELETE", diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs new file mode 100644 index 0000000..b9ccb53 --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs @@ -0,0 +1,103 @@ +use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; + +pub async fn get_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .get_message_reactions_by_owner_message(user.id, id) + .await + { + Ok(r) => Json(ApiReturn { + ok: true, + message: "Reactions exists".to_string(), + payload: Some(r), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let message_id = match req.message.parse::() { + Ok(n) => n, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // check for existing reaction + if let Ok(r) = data + .get_message_reaction_by_owner_message_emoji(user.id, message_id, &req.emoji) + .await + { + if let Err(e) = data.delete_message_reaction(r.id, &user).await { + return Json(e.into()); + } else { + return Json(ApiReturn { + ok: true, + message: "Reaction removed".to_string(), + payload: (), + }); + } + } + + // create reaction + match data + .create_message_reaction(MessageReaction::new(user.id, message_id, req.emoji), &user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, emoji)): Path<(usize, String)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let reaction = match data + .get_message_reaction_by_owner_message_emoji(user.id, id, &emoji) + .await + { + Ok(r) => r, + Err(e) => return Json(e.into()), + }; + + match data.delete_message_reaction(reaction.id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/channels/mod.rs b/crates/app/src/routes/api/v1/channels/mod.rs index 345897d..33792c3 100644 --- a/crates/app/src/routes/api/v1/channels/mod.rs +++ b/crates/app/src/routes/api/v1/channels/mod.rs @@ -1,2 +1,3 @@ pub mod channels; +pub mod message_reactions; pub mod messages; diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 6f0d037..1db4c0c 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -16,12 +16,16 @@ use tetratto_core::model::{ /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { - match emojis::get(&emoji) { - Some(e) => match e.shortcode() { - Some(s) => s.to_string(), - None => e.name().replace(" ", "-"), + match emoji.as_str() { + "👍" => "thumbs_up".to_string(), + "👎" => "thumbs_down".to_string(), + _ => match emojis::get(&emoji) { + Some(e) => match e.shortcode() { + Some(s) => s.to_string(), + None => e.name().replace(" ", "-"), + }, + None => String::new(), }, - None => String::new(), } } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9217437..d529c60 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -41,6 +41,19 @@ pub fn routes() -> Router { .route("/reactions", post(reactions::create_request)) .route("/reactions/{id}", get(reactions::get_request)) .route("/reactions/{id}", delete(reactions::delete_request)) + // message reactions + .route( + "/message_reactions", + post(channels::message_reactions::create_request), + ) + .route( + "/message_reactions/{id}", + get(channels::message_reactions::get_request), + ) + .route( + "/message_reactions/{id}/{emoji}", + delete(channels::message_reactions::delete_request), + ) // communities .route( "/communities/find/{id}", @@ -907,3 +920,9 @@ pub struct UpdateNoteContent { pub struct RenderMarkdown { pub content: String, } + +#[derive(Deserialize)] +pub struct CreateMessageReaction { + pub message: String, + pub emoji: String, +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 1c1bda2..3c22a3e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -389,6 +389,46 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stackblocks + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete journals + let res = execute!( + &conn, + "DELETE FROM journals WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!(&conn, "DELETE FROM notes WHERE owner = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete message reactions + let res = execute!( + &conn, + "DELETE FROM message_reactions WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete user follows... individually since it requires updating user counts for follow in self.get_userfollows_by_receiver_all(id).await? { self.delete_userfollow(follow.id, &user, true).await?; diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 36bbdb7..0841e15 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -38,6 +38,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); + execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 64a9dfc..6b7902e 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -25,3 +25,4 @@ pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"); pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); +pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); diff --git a/crates/core/src/database/drivers/sql/create_message_reactions.sql b/crates/core/src/database/drivers/sql/create_message_reactions.sql new file mode 100644 index 0000000..f13a033 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_message_reactions.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS message_reactions ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + message BIGINT NOT NULL, + emoji TEXT NOT NULL, + UNIQUE (owner, message, emoji) +) diff --git a/crates/core/src/database/drivers/sql/create_messages.sql b/crates/core/src/database/drivers/sql/create_messages.sql index 24096b6..235d8dc 100644 --- a/crates/core/src/database/drivers/sql/create_messages.sql +++ b/crates/core/src/database/drivers/sql/create_messages.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages ( created BIGINT NOT NULL, edited BIGINT NOT NULL, content TEXT NOT NULL, - context TEXT NOT NULL + context TEXT NOT NULL, + reactions TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_questions.sql b/crates/core/src/database/drivers/sql/create_questions.sql index ab23661..d314664 100644 --- a/crates/core/src/database/drivers/sql/create_questions.sql +++ b/crates/core/src/database/drivers/sql/create_questions.sql @@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions ( dislikes INT NOT NULL, -- ... context TEXT NOT NULL, - ip TEXT NOT NULL + ip TEXT NOT NULL, + drawings TEXT NOT NULL ) diff --git a/crates/core/src/database/message_reactions.rs b/crates/core/src/database/message_reactions.rs new file mode 100644 index 0000000..4134049 --- /dev/null +++ b/crates/core/src/database/message_reactions.rs @@ -0,0 +1,183 @@ +use oiseau::{cache::Cache, query_rows}; +use crate::model::{ + Error, Result, + auth::{Notification, User}, + permissions::FinePermission, + channels::MessageReaction, +}; +use crate::{auto_method, DataManager}; + +use oiseau::{PostgresRow, execute, get, query_row, params}; + +impl DataManager { + /// Get a [`MessageReaction`] from an SQL row. + pub(crate) fn get_message_reaction_from_row(x: &PostgresRow) -> MessageReaction { + MessageReaction { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + message: get!(x->3(i64)) as usize, + emoji: get!(x->4(String)), + } + } + + auto_method!(get_message_reaction_by_id()@get_message_reaction_from_row -> "SELECT * FROM message_reactions WHERE id = $1" --name="message_reaction" --returns=MessageReaction --cache-key-tmpl="atto.message_reaction:{}"); + + /// Get message_reactions by `owner` and `message`. + pub async fn get_message_reactions_by_owner_message( + &self, + owner: usize, + message: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM message_reactions WHERE owner = $1 AND message = $2", + &[&(owner as i64), &(message as i64)], + |x| { Self::get_message_reaction_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("message_reaction".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a message_reaction by `owner`, `message`, and `emoji`. + pub async fn get_message_reaction_by_owner_message_emoji( + &self, + owner: usize, + message: usize, + emoji: &str, + ) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM message_reactions WHERE owner = $1 AND message = $2 AND emoji = $3", + params![&(owner as i64), &(message as i64), &emoji], + |x| { Ok(Self::get_message_reaction_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("message_reaction".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new message_reaction in the database. + /// + /// # Arguments + /// * `data` - a mock [`MessageReaction`] object to insert + pub async fn create_message_reaction(&self, data: MessageReaction, user: &User) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let mut message = self.get_message_by_id(data.message).await?; + let channel = self.get_channel_by_id(message.channel).await?; + + // ... + let res = execute!( + &conn, + "INSERT INTO message_reactions VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &(data.message as i64), + &data.emoji + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // incr corresponding + if let Some(x) = message.reactions.get(&data.emoji) { + message.reactions.insert(data.emoji.clone(), x + 1); + } else { + message.reactions.insert(data.emoji.clone(), 1); + } + + self.update_message_reactions(message.id, message.reactions) + .await?; + + // send notif + if message.owner != user.id { + self + .create_notification(Notification::new( + "Your message has received a reaction!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has reacted \"{}\" to your [message](/chats/{}/{}?message={})!", + user.username, user.id, data.emoji, channel.community, channel.id, message.id + ), + message.owner, + )) + .await?; + } + + // return + Ok(()) + } + + pub async fn delete_message_reaction(&self, id: usize, user: &User) -> Result<()> { + let message_reaction = self.get_message_reaction_by_id(id).await?; + + if user.id != message_reaction.owner + && !user.permissions.check(FinePermission::MANAGE_REACTIONS) + { + return Err(Error::NotAllowed); + } + + let mut message = self.get_message_by_id(message_reaction.message).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM message_reactions WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0 + .1 + .remove(format!("atto.message_reaction:{}", id)) + .await; + + // decr message reaction count + if let Some(x) = message.reactions.get(&message_reaction.emoji) { + if *x == 1 { + // there are no 0 of this reaction + message.reactions.remove(&message_reaction.emoji); + } else { + // decr 1 + message.reactions.insert(message_reaction.emoji, x - 1); + } + } + + self.update_message_reactions(message.id, message.reactions) + .await?; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 6c60cd7..64157f0 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -31,6 +31,7 @@ impl DataManager { edited: get!(x->4(i64)) as usize, content: get!(x->5(String)), context: serde_json::from_str(&get!(x->6(String))).unwrap(), + reactions: serde_json::from_str(&get!(x->7(String))).unwrap(), } } @@ -218,7 +219,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", params![ &(data.id as i64), &(data.channel as i64), @@ -226,7 +227,8 @@ impl DataManager { &(data.created as i64), &(data.edited as i64), &data.content, - &serde_json::to_string(&data.context).unwrap() + &serde_json::to_string(&data.context).unwrap(), + &serde_json::to_string(&data.reactions).unwrap(), ] ); @@ -357,4 +359,6 @@ impl DataManager { // return Ok(()) } + + auto_method!(update_message_reactions(HashMap) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}"); } diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index a00fde9..774b345 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -12,6 +12,7 @@ mod ipbans; mod ipblocks; mod journals; mod memberships; +mod message_reactions; mod messages; mod notes; mod notifications; diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs index b7023d3..84180c4 100644 --- a/crates/core/src/model/channels.rs +++ b/crates/core/src/model/channels.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; @@ -79,6 +81,7 @@ pub struct Message { pub edited: usize, pub content: String, pub context: MessageContext, + pub reactions: HashMap, } impl Message { @@ -93,6 +96,7 @@ impl Message { edited: now, content, context: MessageContext, + reactions: HashMap::new(), } } } @@ -105,3 +109,25 @@ impl Default for MessageContext { Self } } + +#[derive(Clone, Serialize, Deserialize)] +pub struct MessageReaction { + pub id: usize, + pub created: usize, + pub owner: usize, + pub message: usize, + pub emoji: String, +} + +impl MessageReaction { + /// Create a new [`MessageReaction`]. + pub fn new(owner: usize, message: usize, emoji: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + message, + emoji, + } + } +} diff --git a/sql_changes/messages_reactions.sql b/sql_changes/messages_reactions.sql new file mode 100644 index 0000000..684e890 --- /dev/null +++ b/sql_changes/messages_reactions.sql @@ -0,0 +1,2 @@ +ALTER TABLE messages +ADD COLUMN reactions TEXT NOT NULL DEFAULT '{}';