add: chat message reactions
This commit is contained in:
parent
a4298f95f6
commit
a37312fecf
20 changed files with 557 additions and 25 deletions
|
@ -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> {
|
||||
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();
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
103
crates/app/src/routes/api/v1/channels/message_reactions.rs
Normal file
103
crates/app/src/routes/api/v1/channels/message_reactions.rs
Normal 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()),
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod channels;
|
||||
pub mod message_reactions;
|
||||
pub mod messages;
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue