use mzrs_proto::api::MessageMention;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SpanKind {
#[default]
Bold,
Italic,
Triple,
Code,
Pre,
Link,
Voice,
YouTube,
Strikethrough,
Custom(String),
}
impl SpanKind {
fn as_str(&self) -> &str {
match self {
SpanKind::Bold => "b",
SpanKind::Italic => "s",
SpanKind::Triple => "t",
SpanKind::Code => "c",
SpanKind::Pre => "pre",
SpanKind::Link => "lk",
SpanKind::Voice => "vk",
SpanKind::YouTube => "lk_yt",
SpanKind::Strikethrough => "st",
SpanKind::Custom(s) => s.as_str(),
}
}
}
impl Serialize for SpanKind {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SpanKind {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Ok(match raw.as_str() {
"b" => SpanKind::Bold,
"s" => SpanKind::Italic,
"t" => SpanKind::Triple,
"c" => SpanKind::Code,
"pre" => SpanKind::Pre,
"lk" => SpanKind::Link,
"vk" => SpanKind::Voice,
"lk_yt" => SpanKind::YouTube,
"st" => SpanKind::Strikethrough,
_ => SpanKind::Custom(raw),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormattingSpan {
#[serde(rename = "type")]
pub kind: SpanKind,
pub s: usize,
pub e: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub l: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HashtagSpan {
#[serde(rename = "channelId")]
pub channel_id: String,
pub s: usize,
pub e: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmojiSpan {
pub emojiid: String,
pub s: usize,
pub e: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LinkSpan {
pub s: usize,
pub e: usize,
}
pub struct RichTextParts {
pub text: String,
pub mk: Vec<FormattingSpan>,
pub hg: Vec<HashtagSpan>,
pub ej: Vec<EmojiSpan>,
pub lk: Vec<LinkSpan>,
pub vk: Vec<LinkSpan>,
pub mentions: Vec<MessageMention>,
}
#[derive(Serialize)]
struct RichTextJson {
#[serde(rename = "t", skip_serializing_if = "str::is_empty")]
t: String,
#[serde(rename = "mk", skip_serializing_if = "Vec::is_empty")]
mk: Vec<FormattingSpan>,
#[serde(rename = "hg", skip_serializing_if = "Vec::is_empty")]
hg: Vec<HashtagSpan>,
#[serde(rename = "ej", skip_serializing_if = "Vec::is_empty")]
ej: Vec<EmojiSpan>,
#[serde(rename = "lk", skip_serializing_if = "Vec::is_empty")]
lk: Vec<LinkSpan>,
#[serde(rename = "vk", skip_serializing_if = "Vec::is_empty")]
vk: Vec<LinkSpan>,
}
#[derive(Debug, Default)]
pub struct RichText {
buf: String,
pos: usize,
mk: Vec<FormattingSpan>,
mentions: Vec<MessageMention>,
hg: Vec<HashtagSpan>,
ej: Vec<EmojiSpan>,
lk: Vec<LinkSpan>,
vk: Vec<LinkSpan>,
}
impl RichText {
pub fn new() -> Self {
Self::default()
}
fn u16len(s: &str) -> usize {
s.encode_utf16().count()
}
fn push(&mut self, s: &str) {
self.buf.push_str(s);
self.pos += Self::u16len(s);
}
pub fn text(mut self, t: &str) -> Self {
self.push(t);
self
}
pub fn newline(mut self) -> Self {
self.push("\n");
self
}
pub fn bold(mut self, t: &str) -> Self {
let s = self.pos;
self.push(t);
self.mk.push(FormattingSpan {
kind: SpanKind::Bold,
s,
e: self.pos,
l: None,
});
self
}
pub fn italic(mut self, t: &str) -> Self {
let s = self.pos;
self.push(t);
self.mk.push(FormattingSpan {
kind: SpanKind::Italic,
s,
e: self.pos,
l: None,
});
self
}
pub fn strikethrough(mut self, t: &str) -> Self {
let s = self.pos;
self.push(t);
self.mk.push(FormattingSpan {
kind: SpanKind::Strikethrough,
s,
e: self.pos,
l: None,
});
self
}
pub fn code(mut self, t: &str) -> Self {
let s = self.pos;
self.push(t);
self.mk.push(FormattingSpan {
kind: SpanKind::Code,
s,
e: self.pos,
l: None,
});
self
}
pub fn code_block(mut self, code: &str) -> Self {
let s = self.pos;
self.push(code);
self.mk.push(FormattingSpan {
kind: SpanKind::Pre,
s,
e: self.pos,
l: None,
});
self
}
pub fn code_block_lang(mut self, code: &str, lang: &str) -> Self {
let s = self.pos;
self.push(code);
self.mk.push(FormattingSpan {
kind: SpanKind::Pre,
s,
e: self.pos,
l: Some(lang.to_owned()),
});
self
}
pub fn link(mut self, url: &str) -> Self {
let s = self.pos;
self.push(url);
let e = self.pos;
self.lk.push(LinkSpan { s, e });
self.mk.push(FormattingSpan {
kind: SpanKind::Link,
s,
e,
l: None,
});
self
}
pub fn voice_link(mut self, url: &str) -> Self {
let s = self.pos;
self.push(url);
let e = self.pos;
self.vk.push(LinkSpan { s, e });
self.mk.push(FormattingSpan {
kind: SpanKind::Voice,
s,
e,
l: None,
});
self
}
pub fn hashtag(mut self, channel_id: impl Into<String>, name: &str) -> Self {
let s = self.pos;
self.push(&format!("#{name}"));
self.hg.push(HashtagSpan {
channel_id: channel_id.into(),
s,
e: self.pos,
});
self
}
pub fn emoji(mut self, emoji_id: impl Into<String>, shortcode: &str) -> Self {
let s = self.pos;
self.push(shortcode);
self.ej.push(EmojiSpan {
emojiid: emoji_id.into(),
s,
e: self.pos,
});
self
}
pub fn mention_user(mut self, user_id: i64, username: &str) -> Self {
let s = self.pos as i32;
self.push(&format!("@{username}"));
let e = self.pos as i32;
self.mentions.push(MessageMention {
user_id,
username: username.to_owned(),
s,
e,
..Default::default()
});
self
}
pub fn mention_role(mut self, role_id: i64, rolename: &str) -> Self {
let s = self.pos as i32;
self.push(&format!("@{rolename}"));
let e = self.pos as i32;
self.mentions.push(MessageMention {
role_id,
rolename: rolename.to_owned(),
s,
e,
..Default::default()
});
self
}
pub fn into_parts(self) -> RichTextParts {
RichTextParts {
text: self.buf,
mk: self.mk,
hg: self.hg,
ej: self.ej,
lk: self.lk,
vk: self.vk,
mentions: self.mentions,
}
}
pub fn finish(self) -> (String, Vec<MessageMention>) {
let mentions = self.mentions.clone();
let json = serde_json::to_string(&RichTextJson {
t: self.buf,
mk: self.mk,
hg: self.hg,
ej: self.ej,
lk: self.lk,
vk: self.vk,
})
.unwrap_or_else(|_| "{}".into());
(json, mentions)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_no_mk() {
let (json, mentions) = RichText::new().text("hello").finish();
assert!(json.contains(r#""t":"hello"#));
assert!(!json.contains("mk"));
assert!(mentions.is_empty());
}
#[test]
fn bold_generates_mk_span() {
let (json, _) = RichText::new().bold("hi").finish();
assert!(json.contains(r#""type":"b"#));
assert!(json.contains(r#""s":0"#));
assert!(json.contains(r#""e":2"#));
}
#[test]
fn utf16_positions_for_emoji() {
let (json, _) = RichText::new().text("\u{1F980}").bold("ok").finish();
assert!(json.contains(r#""s":2"#));
assert!(json.contains(r#""e":4"#));
}
#[test]
fn mention_user_generates_proto() {
let (_, mentions) = RichText::new()
.text("Hello ")
.mention_user(42, "alice")
.finish();
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].user_id, 42);
assert_eq!(mentions[0].username, "alice");
}
}