mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
Use rich text format for chat
This commit is contained in:
parent
4d3371e485
commit
c492bb2537
8 changed files with 361 additions and 28 deletions
75
Cargo.lock
generated
75
Cargo.lock
generated
|
@ -107,7 +107,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -231,6 +231,7 @@ dependencies = [
|
|||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"html-escape",
|
||||
"humantime",
|
||||
"rand_core",
|
||||
"rusqlite",
|
||||
|
@ -239,7 +240,8 @@ dependencies = [
|
|||
"serde-aux",
|
||||
"serde-constant",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"serde_tuple",
|
||||
"syn 2.0.76",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
|
@ -350,7 +352,7 @@ dependencies = [
|
|||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -430,7 +432,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -583,7 +585,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -697,6 +699,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||
dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.1.0"
|
||||
|
@ -1040,7 +1051,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1099,7 +1110,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1421,7 +1432,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1446,6 +1457,27 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_tuple"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4f025b91216f15a2a32aa39669329a475733590a015835d1783549a56d09427"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_tuple_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_tuple_macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4076151d1a2b688e25aaf236997933c66e18b870d0369f8b248b8ab2be630d7e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
@ -1547,6 +1579,17 @@ version = "2.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.76"
|
||||
|
@ -1656,7 +1699,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1769,7 +1812,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1857,6 +1900,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
@ -1928,7 +1977,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
@ -1962,7 +2011,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
@ -2144,7 +2193,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -13,6 +13,7 @@ clap = { version = "4.5.16", features = ["derive"] }
|
|||
ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] }
|
||||
futures-util = "0.3.30"
|
||||
hex = { version = "0.4.3", features = ["serde"] }
|
||||
html-escape = "0.2.13"
|
||||
humantime = "2.1.0"
|
||||
rand_core = "0.6.4"
|
||||
rusqlite = { version = "0.32.1", features = ["uuid"] }
|
||||
|
@ -21,6 +22,7 @@ serde = { version = "1.0.209", features = ["derive"] }
|
|||
serde-aux = "4.5.0"
|
||||
serde-constant = "0.1.0"
|
||||
serde_json = "1.0.127"
|
||||
serde_tuple = "0.5.0"
|
||||
tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] }
|
||||
tokio-stream = { version = "0.1.15", features = ["sync"] }
|
||||
tower-http = { version = "0.5.2", features = ["cors", "limit"] }
|
||||
|
|
|
@ -5,8 +5,8 @@ use std::{fs, io};
|
|||
use anyhow::{Context, Result};
|
||||
use bitflags::Flags;
|
||||
use blah::types::{
|
||||
ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, RoomMember, RoomMemberList,
|
||||
ServerPermission, UserKey, WithSig,
|
||||
ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, RoomMember,
|
||||
RoomMemberList, ServerPermission, UserKey, WithSig,
|
||||
};
|
||||
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
||||
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
|
||||
|
@ -238,7 +238,10 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
|
|||
text,
|
||||
} => {
|
||||
let key = load_signing_key(&private_key_file)?;
|
||||
let payload = ChatPayload { room, text };
|
||||
let payload = ChatPayload {
|
||||
room,
|
||||
rich_text: RichText::from(text),
|
||||
};
|
||||
let payload = WithSig::sign(&key, &mut OsRng, payload)?;
|
||||
|
||||
let ret = client
|
||||
|
|
|
@ -83,7 +83,7 @@ paths:
|
|||
payload:
|
||||
typ: chat
|
||||
room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd
|
||||
text: helloo
|
||||
rich_text: [["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]]
|
||||
timestamp: 1724966284
|
||||
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
||||
responses:
|
||||
|
|
2
init.sql
2
init.sql
|
@ -28,5 +28,5 @@ CREATE TABLE IF NOT EXISTS `room_item` (
|
|||
`timestamp` INTEGER NOT NULL,
|
||||
`nonce` INTEGER NOT NULL,
|
||||
`sig` BLOB NOT NULL,
|
||||
`message` TEXT NOT NULL
|
||||
`rich_text` TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
|
|
@ -111,10 +111,47 @@ async function showChatMsg(chat) {
|
|||
|
||||
const el = document.createElement('div', {});
|
||||
el.classList.add('msg');
|
||||
el.innerText = `${shortUser} [${time}] [${verifyRet}]: ${chat.signee.payload.text}`;
|
||||
const elHeader = document.createElement('span', {});
|
||||
const elContent = document.createElement('span', {});
|
||||
elHeader.innerText = `${shortUser} [${time}] [${verifyRet}]:`;
|
||||
elContent.innerHTML = richTextToHtml(chat.signee.payload.rich_text);
|
||||
el.appendChild(elHeader);
|
||||
el.appendChild(elContent);
|
||||
appendMsg(el)
|
||||
}
|
||||
|
||||
function richTextToHtml(richText) {
|
||||
let ret = ''
|
||||
for (let [text, attrs] of richText) {
|
||||
if (attrs === undefined) attrs = {};
|
||||
// Incomplete cases.
|
||||
const tags = [
|
||||
[attrs.b, 'b'],
|
||||
[attrs.i, 'i'],
|
||||
[attrs.m, 'code'],
|
||||
[attrs.s, 'strike'],
|
||||
[attrs.u, 'u'],
|
||||
];
|
||||
for (const [cond, tag] of tags) {
|
||||
if (cond) ret += `<${tag}>`;
|
||||
}
|
||||
ret += escapeHtml(text);
|
||||
tags.reverse();
|
||||
for (const [cond, tag] of tags) {
|
||||
if (cond) ret += `</${tag}>`;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
async function connectRoom(url) {
|
||||
if (url === '' || url == roomUrl) return;
|
||||
const match = url.match(/^https?:\/\/.*\/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\/?/);
|
||||
|
@ -200,10 +237,16 @@ async function postChat(text) {
|
|||
chatInput.disabled = true;
|
||||
|
||||
try {
|
||||
let richText;
|
||||
if (text.startsWith('[')) {
|
||||
richText = JSON.parse(text);
|
||||
} else {
|
||||
richText = [[text]];
|
||||
}
|
||||
const signedPayload = await signData({
|
||||
typ: 'chat',
|
||||
rich_text: richText,
|
||||
room: roomUuid,
|
||||
text,
|
||||
});
|
||||
const resp = await fetch(`${roomUrl}/item`, {
|
||||
method: 'POST',
|
||||
|
|
14
src/main.rs
14
src/main.rs
|
@ -247,7 +247,7 @@ async fn room_get_feed(
|
|||
};
|
||||
FeedItem {
|
||||
id: cid.to_string(),
|
||||
content_text: item.signee.payload.text,
|
||||
content_html: item.signee.payload.rich_text.html().to_string(),
|
||||
date_published: humantime::format_rfc3339(time).to_string(),
|
||||
authors: (author,),
|
||||
extra: FeedItemExtra {
|
||||
|
@ -292,7 +292,7 @@ struct FeedRoom {
|
|||
#[derive(Debug, Serialize)]
|
||||
struct FeedItem {
|
||||
id: String,
|
||||
content_text: String,
|
||||
content_html: String,
|
||||
date_published: String,
|
||||
authors: (FeedAuthor,),
|
||||
#[serde(rename = "_blah")]
|
||||
|
@ -363,7 +363,7 @@ fn query_room_items(
|
|||
|
||||
let mut stmt = conn.prepare(
|
||||
r"
|
||||
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `message`
|
||||
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
|
||||
FROM `room_item`
|
||||
JOIN `user` USING (`uid`)
|
||||
WHERE `rid` = :rid AND
|
||||
|
@ -389,7 +389,7 @@ fn query_room_items(
|
|||
user: row.get("userkey")?,
|
||||
payload: ChatPayload {
|
||||
room: ruuid,
|
||||
text: row.get("message")?,
|
||||
rich_text: row.get("rich_text")?,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -492,8 +492,8 @@ async fn room_post_item(
|
|||
let cid = conn
|
||||
.query_row(
|
||||
r"
|
||||
INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `message`)
|
||||
VALUES (:rid, :uid, :timestamp, :nonce, :sig, :message)
|
||||
INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`)
|
||||
VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text)
|
||||
RETURNING `cid`
|
||||
",
|
||||
named_params! {
|
||||
|
@ -501,7 +501,7 @@ async fn room_post_item(
|
|||
":uid": uid,
|
||||
":timestamp": chat.signee.timestamp,
|
||||
":nonce": chat.signee.nonce,
|
||||
":message": &chat.signee.payload.text,
|
||||
":rich_text": &chat.signee.payload.rich_text,
|
||||
":sig": chat.sig,
|
||||
},
|
||||
|row| row.get::<_, u64>(0),
|
||||
|
|
238
src/types.rs
238
src/types.rs
|
@ -12,7 +12,8 @@ use ed25519_dalek::{
|
|||
Signature, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH,
|
||||
};
|
||||
use rand_core::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
|
||||
use uuid::Uuid;
|
||||
|
||||
const TIMESTAMP_TOLERENCE: u64 = 90;
|
||||
|
@ -83,8 +84,184 @@ impl<T: Serialize> WithSig<T> {
|
|||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "typ", rename = "chat")]
|
||||
pub struct ChatPayload {
|
||||
pub rich_text: RichText,
|
||||
pub room: Uuid,
|
||||
}
|
||||
|
||||
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
|
||||
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RichText(pub Vec<RichTextPiece>);
|
||||
|
||||
// NB. This field is excluded from field order check, because it has tuple representation.
|
||||
#[derive(Debug, PartialEq, Eq, Serialize_tuple)]
|
||||
pub struct RichTextPiece {
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "is_default::<TextAttrs>")]
|
||||
pub attrs: TextAttrs,
|
||||
}
|
||||
|
||||
/// The protocol representation of `RichTextPiece` which keeps nullity of `attrs` for
|
||||
/// canonicalization check.
|
||||
// NB. This field is excluded from field order check, because it has tuple representation.
|
||||
#[derive(Debug, Deserialize_tuple)]
|
||||
struct RichTextPieceRaw {
|
||||
pub text: String,
|
||||
#[serde(default, skip_serializing_if = "is_default::<TextAttrs>")]
|
||||
pub attrs: Option<TextAttrs>,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RichText {
|
||||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let pieces = <Vec<RichTextPieceRaw>>::deserialize(de)?;
|
||||
if pieces
|
||||
.iter()
|
||||
.any(|p| matches!(&p.attrs, Some(attrs) if is_default(attrs)))
|
||||
{
|
||||
return Err(de::Error::custom("not in canonical form"));
|
||||
}
|
||||
let this = Self(
|
||||
pieces
|
||||
.into_iter()
|
||||
.map(|RichTextPieceRaw { text, attrs }| RichTextPiece {
|
||||
text,
|
||||
attrs: attrs.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if !this.is_canonical() {
|
||||
return Err(de::Error::custom("not in canonical form"));
|
||||
}
|
||||
Ok(this)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This protocol format is quite large. Could use bitflags for database.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TextAttrs {
|
||||
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
|
||||
pub bold: bool,
|
||||
#[serde(default, rename = "m", skip_serializing_if = "is_default")]
|
||||
pub code: bool,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub hashtag: bool,
|
||||
#[serde(default, rename = "i", skip_serializing_if = "is_default")]
|
||||
pub italic: bool,
|
||||
// TODO: Should we validate and/or filter the URL.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub link: Option<String>,
|
||||
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
|
||||
pub strike: bool,
|
||||
#[serde(default, rename = "u", skip_serializing_if = "is_default")]
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl From<&'_ str> for RichText {
|
||||
fn from(text: &'_ str) -> Self {
|
||||
text.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RichText {
|
||||
fn from(text: String) -> Self {
|
||||
if text.is_empty() {
|
||||
Self::default()
|
||||
} else {
|
||||
Self(vec![RichTextPiece {
|
||||
text,
|
||||
attrs: TextAttrs::default(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'_ str> for RichTextPiece {
|
||||
fn from(text: &'_ str) -> Self {
|
||||
text.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RichTextPiece {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
attrs: TextAttrs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RichText {
|
||||
/// Is this rich text valid and in the canonical form?
|
||||
///
|
||||
/// This is automatically enforced by `Deserialize` impl.
|
||||
pub fn is_canonical(&self) -> bool {
|
||||
self.0.iter().all(|p| !p.text.is_empty())
|
||||
&& self.0.windows(2).all(|w| w[0].attrs != w[1].attrs)
|
||||
}
|
||||
|
||||
/// Format the text into plain text, stripping all styles.
|
||||
pub fn plain_text(&self) -> impl fmt::Display + '_ {
|
||||
struct Fmt<'a>(&'a RichText);
|
||||
impl fmt::Display for Fmt<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for p in &self.0 .0 {
|
||||
f.write_str(&p.text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Fmt(self)
|
||||
}
|
||||
|
||||
/// Format the text into HTML.
|
||||
pub fn html(&self) -> impl fmt::Display + '_ {
|
||||
struct Fmt<'a>(&'a RichText);
|
||||
impl fmt::Display for Fmt<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for p in &self.0 .0 {
|
||||
let tags = [
|
||||
(p.attrs.bold, "<b>", "</b>"),
|
||||
(p.attrs.code, "<code>", "</code>"),
|
||||
(p.attrs.italic, "<i>", "</i>"),
|
||||
(p.attrs.strike, "<strike>", "</strike>"),
|
||||
(p.attrs.underline, "<u>", "</u>"),
|
||||
(p.attrs.hashtag || p.attrs.link.is_some(), "", "</a>"),
|
||||
];
|
||||
for (cond, begin, _) in tags {
|
||||
if cond {
|
||||
f.write_str(begin)?;
|
||||
}
|
||||
}
|
||||
if p.attrs.hashtag {
|
||||
// TODO: Link target for hashtag?
|
||||
write!(f, r#"<a class="hashtag">"#)?;
|
||||
} else if let Some(link) = &p.attrs.link {
|
||||
let href = html_escape::encode_quoted_attribute(link);
|
||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||
let href = html_escape::encode_quoted_attribute(link);
|
||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||
}
|
||||
f.write_str(&p.text)?;
|
||||
for (cond, _, end) in tags.iter().rev() {
|
||||
if *cond {
|
||||
f.write_str(end)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Fmt(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ChatItem = WithSig<ChatPayload>;
|
||||
|
@ -201,6 +378,21 @@ mod sql_impl {
|
|||
}
|
||||
}
|
||||
|
||||
impl ToSql for RichText {
|
||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||
assert!(self.is_canonical());
|
||||
let json = serde_json::to_string(&self).expect("serialization cannot fail");
|
||||
Ok(json.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for RichText {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
serde_json::from_str::<Self>(value.as_str()?)
|
||||
.map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into()))
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_u64_flag {
|
||||
($($name:ident),*) => {
|
||||
$(
|
||||
|
@ -228,11 +420,17 @@ mod sql_impl {
|
|||
mod tests {
|
||||
use std::fmt::Write;
|
||||
|
||||
use syn::visit;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Visitor {
|
||||
errors: String,
|
||||
}
|
||||
|
||||
const SKIP_CHECK_STRUCTS: &[&str] = &["RichTextPiece", "RichTextPieceRaw"];
|
||||
|
||||
impl<'ast> syn::visit::Visit<'ast> for Visitor {
|
||||
fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) {
|
||||
let fields = i
|
||||
|
@ -245,6 +443,12 @@ mod tests {
|
|||
writeln!(self.errors, "unsorted fields: {fields:?}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_struct(&mut self, i: &'ast syn::ItemStruct) {
|
||||
if !SKIP_CHECK_STRUCTS.contains(&&*i.ident.to_string()) {
|
||||
visit::visit_item_struct(self, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -258,4 +462,36 @@ mod tests {
|
|||
panic!("{}", v.errors);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_serde() {
|
||||
let raw =
|
||||
r#"[["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]]"#;
|
||||
let text = serde_json::from_str::<RichText>(raw).unwrap();
|
||||
assert!(text.is_canonical());
|
||||
assert_eq!(
|
||||
text,
|
||||
RichText(vec![
|
||||
"before ".into(),
|
||||
RichTextPiece {
|
||||
text: "bold ".into(),
|
||||
attrs: TextAttrs {
|
||||
bold: true,
|
||||
..TextAttrs::default()
|
||||
}
|
||||
},
|
||||
RichTextPiece {
|
||||
text: "italic bold ".into(),
|
||||
attrs: TextAttrs {
|
||||
italic: true,
|
||||
bold: true,
|
||||
..TextAttrs::default()
|
||||
}
|
||||
},
|
||||
"end".into(),
|
||||
]),
|
||||
);
|
||||
let got = serde_json::to_string(&text).unwrap();
|
||||
assert_eq!(got, raw);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue