add: chat message reactions

This commit is contained in:
trisua 2025-06-21 03:11:29 -04:00
parent a4298f95f6
commit a37312fecf
20 changed files with 557 additions and 25 deletions

View file

@ -38,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
) )
} }
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(CustomEmoji::replace(value.as_str().unwrap()).into())
}
fn color_escape(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> { fn color_escape(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(sanitize::color_escape(value.as_str().unwrap()).into()) 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_staff_badge", check_staff_badge);
tera.register_filter("has_banned", check_banned); tera.register_filter("has_banned", check_banned);
tera.register_filter("remove_script_tags", remove_script_tags); tera.register_filter("remove_script_tags", remove_script_tags);
tera.register_filter("emojis", render_emojis);
let client = Client::new(); let client = Client::new();

View file

@ -109,6 +109,23 @@
("title" "Send") ("title" "Send")
(text "{{ icon \"send-horizontal\" }}")))) (text "{{ icon \"send-horizontal\" }}"))))
(text "{%- endif %}") (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 (script
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\"); (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
window.VIEWING_SINGLE = \"{{ message }}\".length > 0; window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
@ -434,6 +451,7 @@
const clean_text = () => { const clean_text = () => {
trigger(\"atto::clean_date_codes\"); trigger(\"atto::clean_date_codes\");
trigger(\"atto::hooks::online_indicator\"); trigger(\"atto::hooks::online_indicator\");
trigger(\"atto::hooks::check_message_reactions\");
}; };
document.addEventListener( document.addEventListener(

View file

@ -1026,9 +1026,29 @@
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("class" "flex w-full gap-2 justify-between") ("class" "flex w-full gap-2 justify-between")
(span (div
("class" "no_p_margin") ("class" "flex flex-col gap-2")
(text "{{ message.content|markdown|safe }}")) (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 -%}") (text "{% if grouped -%}")
(div (div
("class" "hidden") ("class" "hidden")
@ -1185,13 +1205,15 @@
(text "{{ text \"chats:action.kick_member\" }}"))))) (text "{{ text \"chats:action.kick_member\" }}")))))
(text "{%- endif %}")) (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 (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()") ("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()")
("title" "Emojis") ("title" "Emojis")
("type" "button") ("type" "button")
(text "{{ icon \"smile-plus\" }}")) (text "{{ icon \"smile-plus\" }}"))
(text "{%- endif %}")
(text "{% if render_dialog -%}") (text "{% if render_dialog -%}")
(dialog (dialog
@ -1237,20 +1259,41 @@
} }
if (event.detail.unicode) { if (event.detail.unicode) {
document.getElementById( if (window.EMOJI_PICKER_MODE === \"replace\") {
window.EMOJI_PICKER_TEXT_ID, document.getElementById(
).value += ` :${await ( window.EMOJI_PICKER_TEXT_ID,
await fetch(\"/api/v1/lookup_emoji\", { ).value = `:${await (
method: \"POST\", await fetch(\"/api/v1/lookup_emoji\", {
body: event.detail.unicode, method: \"POST\",
}) body: event.detail.unicode,
).text()}:`; })
).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 { } else {
document.getElementById( if (window.EMOJI_PICKER_MODE === \"replace\") {
window.EMOJI_PICKER_TEXT_ID, document.getElementById(
).value += ` :${event.detail.emoji.shortcodes[0]}:`; 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(); document.getElementById(\"emoji_dialog\").close();
});")) });"))
(div (div

View file

@ -688,6 +688,36 @@ media_theme_pref();
$.OBSERVERS.push(observer); $.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) => { self.define("hooks::tabs:switch", (_, tab) => {
tab = tab.split("?")[0]; tab = tab.split("?")[0];

View file

@ -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) => { self.define("remove_notification", (_, id) => {
fetch(`/api/v1/notifications/${id}`, { fetch(`/api/v1/notifications/${id}`, {
method: "DELETE", method: "DELETE",

View file

@ -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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Json(req): Json<CreateMessageReaction>,
) -> 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::<usize>() {
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<State>,
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()),
}
}

View file

@ -1,2 +1,3 @@
pub mod channels; pub mod channels;
pub mod message_reactions;
pub mod messages; pub mod messages;

View file

@ -16,12 +16,16 @@ use tetratto_core::model::{
/// Expand a unicode emoji into its Gemoji shortcode. /// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
match emojis::get(&emoji) { match emoji.as_str() {
Some(e) => match e.shortcode() { "👍" => "thumbs_up".to_string(),
Some(s) => s.to_string(), "👎" => "thumbs_down".to_string(),
None => e.name().replace(" ", "-"), _ => match emojis::get(&emoji) {
Some(e) => match e.shortcode() {
Some(s) => s.to_string(),
None => e.name().replace(" ", "-"),
},
None => String::new(),
}, },
None => String::new(),
} }
} }

View file

@ -41,6 +41,19 @@ pub fn routes() -> Router {
.route("/reactions", post(reactions::create_request)) .route("/reactions", post(reactions::create_request))
.route("/reactions/{id}", get(reactions::get_request)) .route("/reactions/{id}", get(reactions::get_request))
.route("/reactions/{id}", delete(reactions::delete_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 // communities
.route( .route(
"/communities/find/{id}", "/communities/find/{id}",
@ -907,3 +920,9 @@ pub struct UpdateNoteContent {
pub struct RenderMarkdown { pub struct RenderMarkdown {
pub content: String, pub content: String,
} }
#[derive(Deserialize)]
pub struct CreateMessageReaction {
pub message: String,
pub emoji: String,
}

View file

@ -389,6 +389,46 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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 // delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? { for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?; self.delete_userfollow(follow.id, &user, true).await?;

View file

@ -38,6 +38,7 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
self.0 self.0
.1 .1

View file

@ -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_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql");
pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.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_NOTES: &str = include_str!("./sql/create_notes.sql");
pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");

View file

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

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages (
created BIGINT NOT NULL, created BIGINT NOT NULL,
edited BIGINT NOT NULL, edited BIGINT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
context TEXT NOT NULL context TEXT NOT NULL,
reactions TEXT NOT NULL
) )

View file

@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions (
dislikes INT NOT NULL, dislikes INT NOT NULL,
-- ... -- ...
context TEXT NOT NULL, context TEXT NOT NULL,
ip TEXT NOT NULL ip TEXT NOT NULL,
drawings TEXT NOT NULL
) )

View file

@ -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<Vec<MessageReaction>> {
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<MessageReaction> {
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(())
}
}

View file

@ -31,6 +31,7 @@ impl DataManager {
edited: get!(x->4(i64)) as usize, edited: get!(x->4(i64)) as usize,
content: get!(x->5(String)), content: get!(x->5(String)),
context: serde_json::from_str(&get!(x->6(String))).unwrap(), 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!( let res = execute!(
&conn, &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![ params![
&(data.id as i64), &(data.id as i64),
&(data.channel as i64), &(data.channel as i64),
@ -226,7 +227,8 @@ impl DataManager {
&(data.created as i64), &(data.created as i64),
&(data.edited as i64), &(data.edited as i64),
&data.content, &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 // return
Ok(()) Ok(())
} }
auto_method!(update_message_reactions(HashMap<String, usize>) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}");
} }

View file

@ -12,6 +12,7 @@ mod ipbans;
mod ipblocks; mod ipblocks;
mod journals; mod journals;
mod memberships; mod memberships;
mod message_reactions;
mod messages; mod messages;
mod notes; mod notes;
mod notifications; mod notifications;

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
@ -79,6 +81,7 @@ pub struct Message {
pub edited: usize, pub edited: usize,
pub content: String, pub content: String,
pub context: MessageContext, pub context: MessageContext,
pub reactions: HashMap<String, usize>,
} }
impl Message { impl Message {
@ -93,6 +96,7 @@ impl Message {
edited: now, edited: now,
content, content,
context: MessageContext, context: MessageContext,
reactions: HashMap::new(),
} }
} }
} }
@ -105,3 +109,25 @@ impl Default for MessageContext {
Self 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::<usize>().unwrap(),
created: unix_epoch_timestamp(),
owner,
message,
emoji,
}
}
}

View file

@ -0,0 +1,2 @@
ALTER TABLE messages
ADD COLUMN reactions TEXT NOT NULL DEFAULT '{}';