Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/tui/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,60 @@ fn next_url_start(value: &str, cursor: usize) -> Option<usize> {
}
}

/// Byte ranges of `||spoiler||` spans, pipes included. Discord treats a pair of
/// double pipes wrapping non-empty text as a spoiler; empty `||||` and an
/// unclosed opener are ignored. `|` is ASCII so byte offsets are always char
/// boundaries.
pub(crate) fn spoiler_ranges(value: &str) -> Vec<(usize, usize)> {
let bytes = value.as_bytes();
let mut ranges = Vec::new();
let mut cursor = 0usize;

while let Some(open) = next_double_pipe(bytes, cursor) {
let content_start = open.saturating_add(2);
match next_double_pipe(bytes, content_start) {
// Non-empty content between the pipe pairs is a spoiler.
Some(close) if close > content_start => {
ranges.push((open, close.saturating_add(2)));
cursor = close.saturating_add(2);
}
// Empty `||||`; step past the first pair and keep scanning.
Some(close) => cursor = close.saturating_add(2),
None => break,
}
}

ranges
}

fn next_double_pipe(bytes: &[u8], from: usize) -> Option<usize> {
(from..bytes.len().saturating_sub(1))
.find(|&index| bytes[index] == b'|' && bytes[index + 1] == b'|')
}

#[cfg(test)]
mod spoiler_tests {
use super::spoiler_ranges;

#[test]
fn detects_single_spoiler_including_pipes() {
// `||secret||` occupies bytes 2..12 of the input.
assert_eq!(spoiler_ranges("a ||secret|| b"), vec![(2, 12)]);
}

#[test]
fn detects_multiple_spoilers() {
assert_eq!(spoiler_ranges("||x|| and ||y||"), vec![(0, 5), (10, 15)]);
}

#[test]
fn ignores_empty_unclosed_and_plain_text() {
assert!(spoiler_ranges("|||| nothing").is_empty());
assert!(spoiler_ranges("||unclosed").is_empty());
assert!(spoiler_ranges("no spoiler here").is_empty());
}
}

fn grapheme_is_likely_wide_emoji(grapheme: &str) -> bool {
grapheme.chars().any(|c| {
let cp = c as u32;
Expand Down Expand Up @@ -257,6 +311,8 @@ pub enum TextHighlightKind {
OtherMention,
/// A detected URL that can be opened from message actions.
Url,
/// A `||spoiler||` span that is masked until the reader reveals the message.
Spoiler,
}

pub fn render_user_mentions_with_highlights<U, R, C, H>(
Expand Down
9 changes: 8 additions & 1 deletion src/tui/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,14 @@ fn handle_left_click(
clicks.clear();
return MouseOutcome::handled(None);
}
let command = if clicks.record_left_click(target) {
let double_click = clicks.record_left_click(target);
// A single click on a message toggles its spoiler reveal; the second
// click of a double-click activates the message instead, so a
// double-click ends up revealed-and-activated.
if pane == FocusPane::Messages && !double_click {
state.toggle_selected_message_spoilers();
}
let command = if double_click {
activate_focused_target(state)
} else {
None
Expand Down
26 changes: 24 additions & 2 deletions src/tui/message/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::discord::{MessageKind, MessageSnapshotInfo, MessageState, PollInfo, R
use crate::tui::{
format::{
InlineEmojiSlot, RenderedText, TextHighlight, TextHighlightKind, detected_url_ranges,
truncate_display_width, truncate_text,
spoiler_ranges, truncate_display_width, truncate_text,
},
state::{DashboardState, discord_color},
ui::forum::forum_post_card_lines,
Expand Down Expand Up @@ -387,13 +387,19 @@ pub(in crate::tui) fn format_message_content_sections_with_loaded_custom_emoji_u
.then(|| display_text_with_stickers(message.content.as_deref(), &message.sticker_names))
.flatten();
if let Some(value) = standalone_content {
let rendered = state.render_user_mentions_with_highlights(
let mut rendered = state.render_user_mentions_with_highlights(
message.guild_id,
&message.mentions,
message.mention_everyone,
&message.mention_roles,
&value,
);
// Mask `||spoiler||` spans until the reader reveals this message by
// clicking it. Revealed messages fall through with no spoiler highlight
// so the text renders normally.
if !state.message_spoilers_revealed(message.id) {
rendered.highlights.extend(spoiler_highlights(&rendered.text));
}
lines.extend(wrap_markdown_message_lines_with_loaded_custom_emoji_urls(
state,
rendered,
Expand Down Expand Up @@ -1095,6 +1101,17 @@ fn url_highlights(value: &str) -> Vec<TextHighlight> {
.collect()
}

fn spoiler_highlights(value: &str) -> Vec<TextHighlight> {
spoiler_ranges(value)
.into_iter()
.map(|(start, end)| TextHighlight {
start,
end,
kind: TextHighlightKind::Spoiler,
})
.collect()
}

fn rendered_text_with_loaded_custom_emoji_placeholders(
rendered: RenderedText,
loaded_custom_emoji_urls: &[String],
Expand Down Expand Up @@ -2111,6 +2128,11 @@ pub(in crate::tui) fn mention_highlight_style(kind: TextHighlightKind) -> Style
TextHighlightKind::Url => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
// Foreground matches background so the masked text renders as a solid
// block; revealing the message drops this highlight entirely.
TextHighlightKind::Spoiler => Style::default()
.fg(Color::DarkGray)
.bg(Color::DarkGray),
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/tui/state/dashboard.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use std::collections::HashSet;

use crate::discord::ids::{Id, marker::MessageMarker};
use crate::tui::message::syntax_highlight::SyntaxHighlightCache;

use super::{
Expand All @@ -19,6 +22,9 @@ pub struct DashboardState {
pub(super) requests: RequestTrackingState,
pub(super) layout_cache: LayoutCacheState,
pub(in crate::tui) syntax_highlight_cache: SyntaxHighlightCache,
/// Messages whose `||spoiler||` spans the reader has revealed this session.
/// Ephemeral by design: spoilers re-hide on restart, matching Discord.
pub(super) revealed_spoilers: HashSet<Id<MessageMarker>>,
}

impl DashboardState {
Expand Down
25 changes: 25 additions & 0 deletions src/tui/state/message_viewport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,31 @@ impl DashboardState {
self.messages().get(self.selected_message()).copied()
}

pub(in crate::tui) fn message_spoilers_revealed(&self, message_id: Id<MessageMarker>) -> bool {
self.revealed_spoilers.contains(&message_id)
}

/// Toggle `||spoiler||` reveal for the selected message. Returns `true` only
/// when that message actually contains a spoiler, so clicking an ordinary
/// message never accumulates reveal state.
pub(in crate::tui) fn toggle_selected_message_spoilers(&mut self) -> bool {
let Some((message_id, has_spoiler)) = self.selected_message_state().map(|message| {
let has_spoiler = message.content.as_deref().is_some_and(|content| {
!crate::tui::format::spoiler_ranges(content).is_empty()
});
(message.id, has_spoiler)
}) else {
return false;
};
if !has_spoiler {
return false;
}
if !self.revealed_spoilers.remove(&message_id) {
self.revealed_spoilers.insert(message_id);
}
true
}

pub(crate) fn reply_target_message_state(&self) -> Option<&MessageState> {
let message_id = self.composer.reply_target_message_id?;
self.messages()
Expand Down
35 changes: 35 additions & 0 deletions src/tui/state/tests/message_viewport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,3 +1064,38 @@ fn older_history_request_leaves_empty_page_exhaustion_to_backend() {
})
);
}

#[test]
fn clicking_message_toggles_spoiler_reveal() {
let mut state = state_with_single_message_content("psst ||secret|| ok");
state.focus_pane(FocusPane::Messages);
let message_id = state
.selected_message_state()
.expect("single message is selected")
.id;

// Hidden by default.
assert!(!state.message_spoilers_revealed(message_id));

// First click reveals the spoiler.
assert!(state.toggle_selected_message_spoilers());
assert!(state.message_spoilers_revealed(message_id));

// Clicking again hides it.
assert!(state.toggle_selected_message_spoilers());
assert!(!state.message_spoilers_revealed(message_id));
}

#[test]
fn toggling_message_without_spoiler_is_a_noop() {
let mut state = state_with_single_message_content("just plain text");
state.focus_pane(FocusPane::Messages);
let message_id = state
.selected_message_state()
.expect("single message is selected")
.id;

// No spoiler content, so the toggle reports no change and stores nothing.
assert!(!state.toggle_selected_message_spoilers());
assert!(!state.message_spoilers_revealed(message_id));
}
Loading