292 lines
8 KiB
Rust
292 lines
8 KiB
Rust
use std::mem;
|
|
|
|
use crossterm::style::{ContentStyle, Stylize};
|
|
use euphoxide::api::{MessageId, Snowflake, Time};
|
|
use time::OffsetDateTime;
|
|
use toss::styled::Styled;
|
|
|
|
use crate::store::Msg;
|
|
use crate::ui::ChatMsg;
|
|
|
|
use super::util;
|
|
|
|
fn nick_char(ch: char) -> bool {
|
|
// Closely following the heim mention regex:
|
|
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
|
// `>` has been experimentally confirmed to delimit mentions as well.
|
|
match ch {
|
|
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
|
_ => !ch.is_whitespace(),
|
|
}
|
|
}
|
|
|
|
fn room_char(ch: char) -> bool {
|
|
// Basically just \w, see also
|
|
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
|
ch.is_ascii_alphanumeric() || ch == '_'
|
|
}
|
|
|
|
enum Span {
|
|
Nothing,
|
|
Mention,
|
|
Room,
|
|
Emoji,
|
|
}
|
|
|
|
struct Highlighter<'a> {
|
|
content: &'a str,
|
|
base_style: ContentStyle,
|
|
exact: bool,
|
|
|
|
span: Span,
|
|
span_start: usize,
|
|
room_or_mention_possible: bool,
|
|
|
|
result: Styled,
|
|
}
|
|
|
|
impl<'a> Highlighter<'a> {
|
|
/// Does *not* guarantee `self.span_start == idx` after running!
|
|
fn close_mention(&mut self, idx: usize) {
|
|
let span_length = idx.saturating_sub(self.span_start);
|
|
if span_length <= 1 {
|
|
// We can repurpose the current span
|
|
self.span = Span::Nothing;
|
|
return;
|
|
}
|
|
|
|
let text = &self.content[self.span_start..idx]; // Includes @
|
|
self.result = mem::take(&mut self.result).and_then(if self.exact {
|
|
util::style_nick_exact(text, self.base_style)
|
|
} else {
|
|
util::style_nick(text, self.base_style)
|
|
});
|
|
|
|
self.span = Span::Nothing;
|
|
self.span_start = idx;
|
|
}
|
|
|
|
/// Does *not* guarantee `self.span_start == idx` after running!
|
|
fn close_room(&mut self, idx: usize) {
|
|
let span_length = idx.saturating_sub(self.span_start);
|
|
if span_length <= 1 {
|
|
// We can repurpose the current span
|
|
self.span = Span::Nothing;
|
|
return;
|
|
}
|
|
|
|
self.result = mem::take(&mut self.result).then(
|
|
&self.content[self.span_start..idx],
|
|
self.base_style.blue().bold(),
|
|
);
|
|
|
|
self.span = Span::Nothing;
|
|
self.span_start = idx;
|
|
}
|
|
|
|
// Warning: `idx` is the index of the closing colon.
|
|
fn close_emoji(&mut self, idx: usize) {
|
|
let name = &self.content[self.span_start + 1..idx];
|
|
if let Some(replace) = util::EMOJI.get(name) {
|
|
match replace {
|
|
Some(replace) if !self.exact => {
|
|
self.result = mem::take(&mut self.result).then(replace, self.base_style);
|
|
}
|
|
_ => {
|
|
let text = &self.content[self.span_start..=idx];
|
|
let style = self.base_style.magenta();
|
|
self.result = mem::take(&mut self.result).then(text, style);
|
|
}
|
|
}
|
|
|
|
self.span = Span::Nothing;
|
|
self.span_start = idx + 1;
|
|
} else {
|
|
self.close_plain(idx);
|
|
self.span = Span::Emoji;
|
|
}
|
|
}
|
|
|
|
/// Guarantees `self.span_start == idx` after running.
|
|
fn close_plain(&mut self, idx: usize) {
|
|
if self.span_start == idx {
|
|
// Span has length 0
|
|
return;
|
|
}
|
|
|
|
self.result =
|
|
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
|
|
|
|
self.span = Span::Nothing;
|
|
self.span_start = idx;
|
|
}
|
|
|
|
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
|
|
match self.span {
|
|
Span::Mention if !nick_char(char) => self.close_mention(idx),
|
|
Span::Room if !room_char(char) => self.close_room(idx),
|
|
Span::Emoji if char == '&' || char == '@' => {
|
|
self.span = Span::Nothing;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
|
|
match self.span {
|
|
Span::Nothing if char == '@' && self.room_or_mention_possible => {
|
|
self.close_plain(idx);
|
|
self.span = Span::Mention;
|
|
}
|
|
Span::Nothing if char == '&' && self.room_or_mention_possible => {
|
|
self.close_plain(idx);
|
|
self.span = Span::Room;
|
|
}
|
|
Span::Nothing if char == ':' => {
|
|
self.close_plain(idx);
|
|
self.span = Span::Emoji;
|
|
}
|
|
Span::Emoji if char == ':' => self.close_emoji(idx),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn close_final_span(&mut self) {
|
|
let idx = self.content.len();
|
|
if self.span_start >= idx {
|
|
return; // Span has no contents
|
|
}
|
|
|
|
match self.span {
|
|
Span::Mention => self.close_mention(idx),
|
|
Span::Room => self.close_room(idx),
|
|
_ => {}
|
|
}
|
|
|
|
self.close_plain(idx);
|
|
}
|
|
|
|
fn step(&mut self, idx: usize, char: char) {
|
|
if self.span_start < idx {
|
|
self.close_span_before_current_char(idx, char);
|
|
}
|
|
|
|
self.update_span_with_current_char(idx, char);
|
|
|
|
// More permissive than the heim web client
|
|
self.room_or_mention_possible = !char.is_alphanumeric();
|
|
}
|
|
|
|
fn highlight(content: &'a str, base_style: ContentStyle, exact: bool) -> Styled {
|
|
let mut this = Self {
|
|
content: if exact { content } else { content.trim() },
|
|
base_style,
|
|
exact,
|
|
span: Span::Nothing,
|
|
span_start: 0,
|
|
room_or_mention_possible: true,
|
|
result: Styled::default(),
|
|
};
|
|
|
|
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
|
|
this.step(idx, char);
|
|
}
|
|
|
|
this.close_final_span();
|
|
|
|
this.result
|
|
}
|
|
}
|
|
|
|
fn highlight_content(content: &str, base_style: ContentStyle, exact: bool) -> Styled {
|
|
Highlighter::highlight(content, base_style, exact)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SmallMessage {
|
|
pub id: MessageId,
|
|
pub parent: Option<MessageId>,
|
|
pub time: Time,
|
|
pub nick: String,
|
|
pub content: String,
|
|
pub seen: bool,
|
|
}
|
|
|
|
fn as_me(content: &str) -> Option<&str> {
|
|
content.strip_prefix("/me")
|
|
}
|
|
|
|
fn style_me() -> ContentStyle {
|
|
ContentStyle::default().grey().italic()
|
|
}
|
|
|
|
fn styled_nick(nick: &str) -> Styled {
|
|
Styled::new_plain("[")
|
|
.and_then(util::style_nick(nick, ContentStyle::default()))
|
|
.then_plain("]")
|
|
}
|
|
|
|
fn styled_nick_me(nick: &str) -> Styled {
|
|
let style = style_me();
|
|
Styled::new("*", style).and_then(util::style_nick(nick, style))
|
|
}
|
|
|
|
fn styled_content(content: &str) -> Styled {
|
|
highlight_content(content.trim(), ContentStyle::default(), false)
|
|
}
|
|
|
|
fn styled_content_me(content: &str) -> Styled {
|
|
let style = style_me();
|
|
highlight_content(content.trim(), style, false).then("*", style)
|
|
}
|
|
|
|
fn styled_editor_content(content: &str) -> Styled {
|
|
let style = if as_me(content).is_some() {
|
|
style_me()
|
|
} else {
|
|
ContentStyle::default()
|
|
};
|
|
highlight_content(content, style, true)
|
|
}
|
|
|
|
impl Msg for SmallMessage {
|
|
type Id = MessageId;
|
|
|
|
fn id(&self) -> Self::Id {
|
|
self.id
|
|
}
|
|
|
|
fn parent(&self) -> Option<Self::Id> {
|
|
self.parent
|
|
}
|
|
|
|
fn seen(&self) -> bool {
|
|
self.seen
|
|
}
|
|
|
|
fn last_possible_id() -> Self::Id {
|
|
MessageId(Snowflake::MAX)
|
|
}
|
|
}
|
|
|
|
impl ChatMsg for SmallMessage {
|
|
fn time(&self) -> OffsetDateTime {
|
|
self.time.0
|
|
}
|
|
|
|
fn styled(&self) -> (Styled, Styled) {
|
|
Self::pseudo(&self.nick, &self.content)
|
|
}
|
|
|
|
fn edit(nick: &str, content: &str) -> (Styled, Styled) {
|
|
(styled_nick(nick), styled_editor_content(content))
|
|
}
|
|
|
|
fn pseudo(nick: &str, content: &str) -> (Styled, Styled) {
|
|
if let Some(content) = as_me(content) {
|
|
(styled_nick_me(nick), styled_content_me(content))
|
|
} else {
|
|
(styled_nick(nick), styled_content(content))
|
|
}
|
|
}
|
|
}
|