Files
fluxer/fluxer_admin/src/templates/pages/messages_page.rs
T

686 lines
27 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{
error_display::error_alert,
form::{
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
submit_button,
},
message_data::{Message, ordered_messages, value_id},
message_list::{MessageActions, message_deletion_script, message_list},
page_container::{card, page_header},
},
layout::LayoutOptions,
layout::admin_layout_ext,
},
};
use maud::{Markup, html};
use serde_json::Value;
const MESSAGE_BROWSE_SCRIPT: &str = r#"
(function () {
var TOP_LOAD_THRESHOLD_PX = 160;
var BOTTOM_LOAD_THRESHOLD_PX = 160;
var RETRY_BACKOFF_MS = 2500;
function getBasePath(root) {
return document.documentElement.dataset.basePath || root.dataset.basePath || '';
}
function getStatusBarHeight(scrollContainer) {
var bar = scrollContainer.querySelector('[data-message-scroll-status-bar]');
return bar instanceof HTMLElement ? bar.offsetHeight : 0;
}
function setStatus(statusEl, text, isError) {
if (!(statusEl instanceof HTMLElement)) return;
statusEl.textContent = text;
statusEl.classList.toggle('text-red-600', Boolean(isError));
statusEl.classList.toggle('text-neutral-500', !isError);
}
function getRows(scrollContainer) {
return scrollContainer.querySelectorAll('[data-message-row]');
}
function centerMessage(scrollContainer, messageId) {
if (!messageId) return false;
var target = null;
var rows = getRows(scrollContainer);
for (var i = 0; i < rows.length; i++) {
if (rows[i] instanceof HTMLElement && rows[i].dataset.messageId === messageId) {
target = rows[i];
break;
}
}
if (!target) return false;
var scrollRect = scrollContainer.getBoundingClientRect();
var targetRect = target.getBoundingClientRect();
var barHeight = getStatusBarHeight(scrollContainer);
var visibleHeight = Math.max(scrollContainer.clientHeight - barHeight, 1);
var targetTop = scrollContainer.scrollTop + (targetRect.top - scrollRect.top) - barHeight;
scrollContainer.scrollTop = Math.max(0, targetTop - Math.max((visibleHeight - targetRect.height) / 2, 0));
return true;
}
function initScrollRoot(root) {
if (!(root instanceof HTMLElement) || root.dataset.messageScrollInit === 'true') return;
root.dataset.messageScrollInit = 'true';
var scrollContainer = root.querySelector('[data-message-scroll]');
if (!(scrollContainer instanceof HTMLElement)) return;
var mode = root.dataset.mode || 'lookup';
var isBrowse = mode === 'browse';
var channelId = root.dataset.channelId || '';
var focusMessageId = root.dataset.focusMessageId || '';
var oldestMessageId = root.dataset.oldestMessageId || '';
var newestMessageId = root.dataset.newestMessageId || '';
var hasMore = root.dataset.hasMore === 'true';
var hasNewer = root.dataset.hasNewer === 'true';
var loading = false;
var loadingNewer = false;
var cooldownUntil = 0;
var newerCooldownUntil = 0;
var listContainer = root.querySelector('[data-browse-list]');
var statusEl = root.querySelector('[data-browse-status]');
var anchorEl = root.querySelector('[data-message-anchor-status]');
if (anchorEl instanceof HTMLElement && focusMessageId) {
anchorEl.textContent = 'Jump to anchor ' + focusMessageId;
anchorEl.classList.remove('text-neutral-400');
anchorEl.classList.add('cursor-pointer', 'text-blue-600', 'hover:underline');
anchorEl.setAttribute('role', 'button');
anchorEl.setAttribute('tabindex', '0');
anchorEl.addEventListener('click', function () {
centerMessage(scrollContainer, focusMessageId);
});
}
function needsPrefill() {
return scrollContainer.scrollHeight <= scrollContainer.clientHeight + 1;
}
function isNearBottom() {
return scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= BOTTOM_LOAD_THRESHOLD_PX;
}
async function loadFragment(direction) {
if (!isBrowse || !channelId || !(listContainer instanceof HTMLElement)) return;
var older = direction === 'older';
if (older) {
if (loading || !hasMore || !oldestMessageId || Date.now() < cooldownUntil) return;
loading = true;
} else {
if (loadingNewer || !hasNewer || !newestMessageId || Date.now() < newerCooldownUntil) return;
loadingNewer = true;
}
setStatus(statusEl, older ? 'Loading older messages...' : 'Loading newer messages...', false);
var prevScrollHeight = scrollContainer.scrollHeight;
var prevScrollTop = scrollContainer.scrollTop;
try {
var url = new URL(getBasePath(root) + '/messages/browse-fragment', window.location.origin);
url.searchParams.set('channel_id', channelId);
url.searchParams.set(older ? 'before' : 'after', older ? oldestMessageId : newestMessageId);
var res = await fetch(url.toString(), {credentials: 'same-origin', headers: {'X-Requested-With': 'fetch'}});
if (!res.ok) throw new Error('HTTP ' + res.status);
var doc = new DOMParser().parseFromString(await res.text(), 'text/html');
var fragment = doc.querySelector('[data-browse-fragment]');
if (!(fragment instanceof HTMLElement)) throw new Error('Invalid response');
var count = Number(fragment.dataset.messageCount || '0');
if (older) {
var nextOldest = fragment.dataset.oldestMessageId || '';
hasMore = fragment.dataset.hasMore === 'true';
if (count > 0 && nextOldest && nextOldest !== oldestMessageId) {
listContainer.insertBefore(fragment, listContainer.firstChild);
oldestMessageId = nextOldest;
} else {
hasMore = false;
}
root.dataset.hasMore = hasMore ? 'true' : 'false';
root.dataset.oldestMessageId = oldestMessageId;
} else {
var nextNewest = fragment.dataset.newestMessageId || '';
hasNewer = fragment.dataset.hasMore === 'true';
if (count > 0 && nextNewest && nextNewest !== newestMessageId) {
listContainer.appendChild(fragment);
newestMessageId = nextNewest;
} else {
hasNewer = false;
}
root.dataset.hasNewer = hasNewer ? 'true' : 'false';
root.dataset.newestMessageId = newestMessageId;
}
requestAnimationFrame(function () {
if (older) scrollContainer.scrollTop = prevScrollTop + (scrollContainer.scrollHeight - prevScrollHeight);
setStatus(statusEl, '', false);
loading = false;
loadingNewer = false;
if (older && hasMore && needsPrefill()) loadFragment('older');
if (!older && hasNewer && needsPrefill()) loadFragment('newer');
});
} catch (err) {
console.error('[messages] browse fragment failed:', err);
setStatus(statusEl, older ? 'Could not load older messages. Retrying shortly...' : 'Could not load newer messages. Retrying shortly...', true);
if (older) {
cooldownUntil = Date.now() + RETRY_BACKOFF_MS;
loading = false;
} else {
newerCooldownUntil = Date.now() + RETRY_BACKOFF_MS;
loadingNewer = false;
}
}
}
scrollContainer.addEventListener('scroll', function () {
if (isBrowse && hasMore && !loading && scrollContainer.scrollTop <= TOP_LOAD_THRESHOLD_PX) loadFragment('older');
if (isBrowse && hasNewer && !loadingNewer && isNearBottom()) loadFragment('newer');
}, {passive: true});
requestAnimationFrame(function () {
if (focusMessageId) {
if (!centerMessage(scrollContainer, focusMessageId)) scrollContainer.scrollTop = 0;
} else if (isBrowse) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
if (isBrowse && hasMore && needsPrefill()) loadFragment('older');
if (isBrowse && hasNewer && focusMessageId && needsPrefill()) loadFragment('newer');
});
}
function initAll() {
document.querySelectorAll('[data-message-scroll-root]').forEach(initScrollRoot);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
})();
"#;
pub struct MessagesPageParams<'a> {
pub csrf_token: &'a str,
pub prefill_channel_id: Option<&'a str>,
pub can_lookup: bool,
pub can_delete: bool,
pub can_report_ncmec: bool,
pub lookup_result: Option<&'a Value>,
pub browse_result: Option<&'a Value>,
pub search_result: Option<&'a Value>,
pub browse_channel_id: Option<&'a str>,
pub search_query_text: Option<&'a str>,
pub context_limit: u32,
pub error: Option<&'a str>,
}
pub fn messages_page(
config: &AdminConfig,
auth: &AuthContext,
params: &MessagesPageParams<'_>,
) -> Markup {
let actions = MessageActions {
delete: params.can_delete,
ncmec: params.can_report_ncmec,
source_report_id: None,
};
let content = html! {
(page_header("Message Tools", None))
div class="space-y-6" {
@if let Some(error) = params.error {
(error_alert(error))
}
@if let (Some(result), Some(channel_id), Some(query)) = (
params.search_result, params.browse_channel_id, params.search_query_text,
) {
(search_result_card(config, result, channel_id, query, actions))
}
@if params.search_result.is_none()
&& let (Some(result), Some(channel_id)) = (
params.browse_result, params.browse_channel_id,
) {
(browse_result_card(
config, result, channel_id, actions,
params.csrf_token, params.context_limit,
))
}
@if let Some(result) = params.lookup_result {
(lookup_result_card(config, result, actions, params.context_limit))
}
@if params.can_lookup {
(browse_channel_form(config, params.csrf_token, params.prefill_channel_id))
(lookup_message_form(config, params.csrf_token, params.prefill_channel_id))
(lookup_by_attachment_form(config, params.csrf_token))
}
@if params.can_delete {
(delete_message_form(config, params.csrf_token))
}
(message_deletion_script(params.csrf_token))
}
};
admin_layout_ext(
config,
auth,
"Message Tools",
"message-tools",
None,
content,
LayoutOptions {
extra_scripts: Some(MESSAGE_BROWSE_SCRIPT),
csrf_token: params.csrf_token,
..LayoutOptions::default()
},
)
}
pub fn browse_messages_fragment(
config: &AdminConfig,
result: &Value,
actions: MessageActions<'_>,
highlight_message_id: Option<&str>,
) -> Markup {
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
.unwrap_or(false);
let oldest_message_id = messages.first().map(|m| m.id.as_str()).unwrap_or("");
let newest_message_id = messages.last().map(|m| m.id.as_str()).unwrap_or("");
html! {
div data-browse-fragment=""
data-has-more=(if has_more { "true" } else { "false" })
data-message-count=(messages.len())
data-oldest-message-id=(oldest_message_id)
data-newest-message-id=(newest_message_id) {
(message_list(
config,
&config.base_path,
&messages,
actions,
highlight_message_id,
))
}
}
}
fn browse_result_card(
config: &AdminConfig,
result: &Value,
channel_id: &str,
actions: MessageActions<'_>,
csrf_token: &str,
context_limit: u32,
) -> Markup {
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
.unwrap_or(false);
card(html! {
(result_heading(config, result, channel_id, "Browse Channel: "))
form method="post" action={(config.base_path) "/messages?action=search"} class="mb-4" {
(csrf_input(csrf_token))
input type="hidden" name="channel_id" value=(channel_id);
div class="flex flex-col gap-2 sm:flex-row sm:items-end" {
div class="flex-1" {
(form_field_group(
"Search messages", "browse-search-query", false, None, None,
html! {
input type="text" id="browse-search-query" name="search"
placeholder="Search message content..."
class=(FORM_INPUT_CLASS);
},
))
}
button type="submit" class="inline-flex h-8 items-center justify-center rounded-md border border-neutral-300 bg-white px-3 font-medium text-neutral-700 text-sm hover:bg-neutral-50" {
"Search"
}
}
}
@if messages.is_empty() {
(empty_state("No messages found in this channel."))
} @else {
(message_scroll_pane(MessageScrollPane {
config,
channel_id,
messages: &messages,
actions,
has_more,
has_newer: false,
focus_message_id: None,
context_limit,
}))
}
})
}
fn search_result_card(
config: &AdminConfig,
result: &Value,
channel_id: &str,
query_text: &str,
actions: MessageActions<'_>,
) -> Markup {
let messages = response_messages(result);
let total = result
.get("total")
.and_then(Value::as_u64)
.unwrap_or(messages.len() as u64);
let title = format!("Search results for \"{query_text}\" in ");
card(html! {
(result_heading(config, result, channel_id, &title))
p class="mb-4 text-neutral-500 text-sm" {
(total) " result" @if total != 1 { "s" }
}
div class="mb-4" {
a href={(config.base_path) "/messages?channel_id=" (channel_id)}
class="inline-flex h-8 items-center rounded-md border border-neutral-300 bg-white px-3 font-medium text-neutral-700 text-sm hover:bg-neutral-50" {
"Back to Browse"
}
}
@if messages.is_empty() {
(empty_state("No messages found."))
} @else {
(message_list(
config,
&config.base_path,
&messages,
actions,
None,
))
}
})
}
fn lookup_result_card(
config: &AdminConfig,
result: &Value,
actions: MessageActions<'_>,
context_limit: u32,
) -> Markup {
let messages = response_messages(result);
let channel_id = messages
.first()
.map(|m| m.channel_id.as_str())
.unwrap_or("");
let message_id = result.get("message_id").and_then(Value::as_str);
card(html! {
@if channel_id.is_empty() {
h3 class="mb-4 text-base font-semibold text-neutral-900" { "Lookup Result" }
} @else {
(result_heading(config, result, channel_id, "Lookup Result: "))
}
@if messages.is_empty() {
(empty_state("No messages found for this lookup."))
} @else {
(message_scroll_pane(MessageScrollPane {
config,
channel_id,
messages: &messages,
actions,
has_more: true,
has_newer: true,
focus_message_id: message_id,
context_limit,
}))
}
})
}
struct MessageScrollPane<'a> {
config: &'a AdminConfig,
channel_id: &'a str,
messages: &'a [Message],
actions: MessageActions<'a>,
has_more: bool,
has_newer: bool,
focus_message_id: Option<&'a str>,
context_limit: u32,
}
fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
let oldest_message_id = pane.messages.first().map(|m| m.id.as_str()).unwrap_or("");
let newest_message_id = pane.messages.last().map(|m| m.id.as_str()).unwrap_or("");
html! {
div data-message-scroll-root=""
data-mode="browse"
data-channel-id=(pane.channel_id)
data-focus-message-id=(pane.focus_message_id.unwrap_or(""))
data-has-more=(if pane.has_more { "true" } else { "false" })
data-has-newer=(if pane.has_newer { "true" } else { "false" })
data-oldest-message-id=(oldest_message_id)
data-newest-message-id=(newest_message_id)
data-context-limit=(pane.context_limit) {
div data-message-scroll=""
class="max-h-[70vh] min-h-[26rem] overflow-y-auto overscroll-contain rounded-xl border border-neutral-200 bg-white shadow-inner focus:outline-none focus:ring-2 focus:ring-neutral-300" {
div data-message-scroll-status-bar=""
class="sticky top-0 z-10 border-neutral-200 border-b bg-white/95 px-4 py-2 backdrop-blur" {
div class="flex flex-wrap items-center justify-between gap-2 text-xs" {
span data-browse-status="" class="text-neutral-500" {}
span data-message-anchor-status="" class="text-[11px] text-neutral-400" {}
}
}
div data-browse-list="" class="px-0 py-3" {
div data-browse-fragment=""
data-has-more=(if pane.has_more { "true" } else { "false" })
data-message-count=(pane.messages.len())
data-oldest-message-id=(oldest_message_id)
data-newest-message-id=(newest_message_id) {
(message_list(
pane.config,
&pane.config.base_path,
pane.messages,
pane.actions,
pane.focus_message_id,
))
}
}
}
}
}
}
fn result_heading(config: &AdminConfig, result: &Value, channel_id: &str, prefix: &str) -> Markup {
let first = result
.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.first());
let channel_name = first
.and_then(|m| m.get("channel_name"))
.and_then(Value::as_str);
let guild_id = first.and_then(|m| m.get("guild_id")).and_then(value_id);
let guild_name = first
.and_then(|m| m.get("guild_name"))
.and_then(Value::as_str);
html! {
h3 class="mb-4 text-base font-semibold text-neutral-900" {
(prefix)
@if let Some(name) = channel_name {
a href={(config.base_path) "/messages?channel_id=" (channel_id)}
class="text-blue-600 hover:underline" { "#" (name) }
} @else {
a href={(config.base_path) "/messages?channel_id=" (channel_id)}
class="text-blue-600 hover:underline" { (channel_id) }
}
@if let (Some(gid), Some(gname)) = (guild_id, guild_name) {
" "
span class="text-neutral-400" { "in" }
" "
a href={(config.base_path) "/guilds/" (gid)}
class="text-blue-600 hover:underline" { (gname) }
}
@if channel_name.is_some() {
span class="ml-2 text-neutral-400 text-xs" { (channel_id) }
}
}
}
}
fn empty_state(text: &str) -> Markup {
html! {
div class="rounded-lg border border-dashed border-neutral-300 bg-neutral-50 px-4 py-8 text-center text-neutral-500 text-sm" {
(text)
}
}
}
fn response_messages(result: &Value) -> Vec<Message> {
let values = result["messages"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
ordered_messages(values)
}
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
let base = &config.base_path;
card(html! {
h3 class="mb-4 text-base font-semibold text-neutral-900" {
"Browse Channel"
}
form method="post" action={(base) "/messages?action=browse"} {
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Channel ID", "browse-channel-id", true, None, None,
html! {
input type="text" id="browse-channel-id" name="channel_id"
placeholder="123456789" required
value=[prefill]
class=(FORM_INPUT_CLASS);
},
))
(form_actions(html! {
(submit_button("Browse Channel"))
}))
}
}
})
}
fn lookup_message_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
let base = &config.base_path;
card(html! {
h3 class="mb-4 text-base font-semibold text-neutral-900" {
"Lookup Message"
}
form method="post" action={(base) "/messages?action=lookup"} {
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Channel ID", "lookup-message-channel-id", true, None, None,
html! {
input type="text" id="lookup-message-channel-id" name="channel_id"
placeholder="123456789" required
value=[prefill]
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Message ID", "lookup-message-message-id", true, None, None,
html! {
input type="text" id="lookup-message-message-id" name="message_id"
placeholder="123456789" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Context Limit (messages before and after)",
"lookup-message-context-limit", true, None, None,
html! {
input type="number" id="lookup-message-context-limit"
name="context_limit" value="50" required
class=(FORM_INPUT_CLASS);
},
))
(form_actions(html! {
(submit_button("Lookup Message"))
}))
}
}
})
}
fn lookup_by_attachment_form(config: &AdminConfig, csrf_token: &str) -> Markup {
let base = &config.base_path;
card(html! {
h3 class="mb-4 text-base font-semibold text-neutral-900" {
"Lookup Message by Attachment"
}
form method="post" action={(base) "/messages?action=lookup-by-attachment"} {
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Channel ID", "lookup-by-attachment-channel-id", true, None, None,
html! {
input type="text" id="lookup-by-attachment-channel-id"
name="channel_id" placeholder="123456789" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Attachment ID", "lookup-by-attachment-attachment-id",
true, None, None,
html! {
input type="text" id="lookup-by-attachment-attachment-id"
name="attachment_id" placeholder="123456789" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Filename", "lookup-by-attachment-filename", true, None, None,
html! {
input type="text" id="lookup-by-attachment-filename"
name="filename" placeholder="image.png" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Context Limit (messages before and after)",
"lookup-by-attachment-context-limit", true, None, None,
html! {
input type="number" id="lookup-by-attachment-context-limit"
name="context_limit" value="50" required
class=(FORM_INPUT_CLASS);
},
))
(form_actions(html! {
(submit_button("Lookup by Attachment"))
}))
}
}
})
}
fn delete_message_form(config: &AdminConfig, csrf_token: &str) -> Markup {
let base = &config.base_path;
card(html! {
h3 class="mb-4 text-base font-semibold text-neutral-900" {
"Delete Message"
}
form method="post" action={(base) "/messages?action=delete"} {
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Channel ID", "delete-message-channel-id", true, None, None,
html! {
input type="text" id="delete-message-channel-id"
name="channel_id" placeholder="123456789" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Message ID", "delete-message-message-id", true, None, None,
html! {
input type="text" id="delete-message-message-id"
name="message_id" placeholder="123456789" required
class=(FORM_INPUT_CLASS);
},
))
(form_field_group(
"Audit Log Reason (optional)",
"delete-message-audit-log-reason", false, None, None,
html! {
input type="text" id="delete-message-audit-log-reason"
name="audit_log_reason" placeholder="Reason for deletion"
class=(FORM_INPUT_CLASS);
},
))
(form_actions(html! {
(danger_button("Delete Message"))
}))
}
}
})
}