mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 08:41: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 = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -231,6 +231,7 @@ dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"html-escape",
|
||||||
"humantime",
|
"humantime",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
@ -239,7 +240,8 @@ dependencies = [
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
"serde-constant",
|
"serde-constant",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"syn",
|
"serde_tuple",
|
||||||
|
"syn 2.0.76",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
@ -350,7 +352,7 @@ dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -430,7 +432,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -583,7 +585,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -697,6 +699,15 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
@ -1040,7 +1051,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1099,7 +1110,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1421,7 +1432,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1446,6 +1457,27 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
@ -1547,6 +1579,17 @@ version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.76"
|
version = "2.0.76"
|
||||||
|
@ -1656,7 +1699,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1769,7 +1812,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1857,6 +1900,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-width"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
@ -1928,7 +1977,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1962,7 +2011,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
"wasm-bindgen-backend",
|
"wasm-bindgen-backend",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
@ -2144,7 +2193,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.76",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -13,6 +13,7 @@ clap = { version = "4.5.16", features = ["derive"] }
|
||||||
ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] }
|
ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] }
|
||||||
futures-util = "0.3.30"
|
futures-util = "0.3.30"
|
||||||
hex = { version = "0.4.3", features = ["serde"] }
|
hex = { version = "0.4.3", features = ["serde"] }
|
||||||
|
html-escape = "0.2.13"
|
||||||
humantime = "2.1.0"
|
humantime = "2.1.0"
|
||||||
rand_core = "0.6.4"
|
rand_core = "0.6.4"
|
||||||
rusqlite = { version = "0.32.1", features = ["uuid"] }
|
rusqlite = { version = "0.32.1", features = ["uuid"] }
|
||||||
|
@ -21,6 +22,7 @@ serde = { version = "1.0.209", features = ["derive"] }
|
||||||
serde-aux = "4.5.0"
|
serde-aux = "4.5.0"
|
||||||
serde-constant = "0.1.0"
|
serde-constant = "0.1.0"
|
||||||
serde_json = "1.0.127"
|
serde_json = "1.0.127"
|
||||||
|
serde_tuple = "0.5.0"
|
||||||
tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] }
|
tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] }
|
||||||
tokio-stream = { version = "0.1.15", features = ["sync"] }
|
tokio-stream = { version = "0.1.15", features = ["sync"] }
|
||||||
tower-http = { version = "0.5.2", features = ["cors", "limit"] }
|
tower-http = { version = "0.5.2", features = ["cors", "limit"] }
|
||||||
|
|
|
@ -5,8 +5,8 @@ use std::{fs, io};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitflags::Flags;
|
use bitflags::Flags;
|
||||||
use blah::types::{
|
use blah::types::{
|
||||||
ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, RoomMember, RoomMemberList,
|
ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, RoomMember,
|
||||||
ServerPermission, UserKey, WithSig,
|
RoomMemberList, ServerPermission, UserKey, WithSig,
|
||||||
};
|
};
|
||||||
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
||||||
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
|
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
|
||||||
|
@ -238,7 +238,10 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
|
||||||
text,
|
text,
|
||||||
} => {
|
} => {
|
||||||
let key = load_signing_key(&private_key_file)?;
|
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 payload = WithSig::sign(&key, &mut OsRng, payload)?;
|
||||||
|
|
||||||
let ret = client
|
let ret = client
|
||||||
|
|
|
@ -83,7 +83,7 @@ paths:
|
||||||
payload:
|
payload:
|
||||||
typ: chat
|
typ: chat
|
||||||
room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd
|
room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd
|
||||||
text: helloo
|
rich_text: [["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]]
|
||||||
timestamp: 1724966284
|
timestamp: 1724966284
|
||||||
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
||||||
responses:
|
responses:
|
||||||
|
|
2
init.sql
2
init.sql
|
@ -28,5 +28,5 @@ CREATE TABLE IF NOT EXISTS `room_item` (
|
||||||
`timestamp` INTEGER NOT NULL,
|
`timestamp` INTEGER NOT NULL,
|
||||||
`nonce` INTEGER NOT NULL,
|
`nonce` INTEGER NOT NULL,
|
||||||
`sig` BLOB NOT NULL,
|
`sig` BLOB NOT NULL,
|
||||||
`message` TEXT NOT NULL
|
`rich_text` TEXT NOT NULL
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
|
@ -111,10 +111,47 @@ async function showChatMsg(chat) {
|
||||||
|
|
||||||
const el = document.createElement('div', {});
|
const el = document.createElement('div', {});
|
||||||
el.classList.add('msg');
|
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)
|
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) {
|
async function connectRoom(url) {
|
||||||
if (url === '' || url == roomUrl) return;
|
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})\/?/);
|
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;
|
chatInput.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let richText;
|
||||||
|
if (text.startsWith('[')) {
|
||||||
|
richText = JSON.parse(text);
|
||||||
|
} else {
|
||||||
|
richText = [[text]];
|
||||||
|
}
|
||||||
const signedPayload = await signData({
|
const signedPayload = await signData({
|
||||||
typ: 'chat',
|
typ: 'chat',
|
||||||
|
rich_text: richText,
|
||||||
room: roomUuid,
|
room: roomUuid,
|
||||||
text,
|
|
||||||
});
|
});
|
||||||
const resp = await fetch(`${roomUrl}/item`, {
|
const resp = await fetch(`${roomUrl}/item`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
14
src/main.rs
14
src/main.rs
|
@ -247,7 +247,7 @@ async fn room_get_feed(
|
||||||
};
|
};
|
||||||
FeedItem {
|
FeedItem {
|
||||||
id: cid.to_string(),
|
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(),
|
date_published: humantime::format_rfc3339(time).to_string(),
|
||||||
authors: (author,),
|
authors: (author,),
|
||||||
extra: FeedItemExtra {
|
extra: FeedItemExtra {
|
||||||
|
@ -292,7 +292,7 @@ struct FeedRoom {
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct FeedItem {
|
struct FeedItem {
|
||||||
id: String,
|
id: String,
|
||||||
content_text: String,
|
content_html: String,
|
||||||
date_published: String,
|
date_published: String,
|
||||||
authors: (FeedAuthor,),
|
authors: (FeedAuthor,),
|
||||||
#[serde(rename = "_blah")]
|
#[serde(rename = "_blah")]
|
||||||
|
@ -363,7 +363,7 @@ fn query_room_items(
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r"
|
r"
|
||||||
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `message`
|
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
|
||||||
FROM `room_item`
|
FROM `room_item`
|
||||||
JOIN `user` USING (`uid`)
|
JOIN `user` USING (`uid`)
|
||||||
WHERE `rid` = :rid AND
|
WHERE `rid` = :rid AND
|
||||||
|
@ -389,7 +389,7 @@ fn query_room_items(
|
||||||
user: row.get("userkey")?,
|
user: row.get("userkey")?,
|
||||||
payload: ChatPayload {
|
payload: ChatPayload {
|
||||||
room: ruuid,
|
room: ruuid,
|
||||||
text: row.get("message")?,
|
rich_text: row.get("rich_text")?,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -492,8 +492,8 @@ async fn room_post_item(
|
||||||
let cid = conn
|
let cid = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
r"
|
r"
|
||||||
INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `message`)
|
INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`)
|
||||||
VALUES (:rid, :uid, :timestamp, :nonce, :sig, :message)
|
VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text)
|
||||||
RETURNING `cid`
|
RETURNING `cid`
|
||||||
",
|
",
|
||||||
named_params! {
|
named_params! {
|
||||||
|
@ -501,7 +501,7 @@ async fn room_post_item(
|
||||||
":uid": uid,
|
":uid": uid,
|
||||||
":timestamp": chat.signee.timestamp,
|
":timestamp": chat.signee.timestamp,
|
||||||
":nonce": chat.signee.nonce,
|
":nonce": chat.signee.nonce,
|
||||||
":message": &chat.signee.payload.text,
|
":rich_text": &chat.signee.payload.rich_text,
|
||||||
":sig": chat.sig,
|
":sig": chat.sig,
|
||||||
},
|
},
|
||||||
|row| row.get::<_, u64>(0),
|
|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,
|
Signature, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH,
|
||||||
};
|
};
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||||
|
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const TIMESTAMP_TOLERENCE: u64 = 90;
|
const TIMESTAMP_TOLERENCE: u64 = 90;
|
||||||
|
@ -83,8 +84,184 @@ impl<T: Serialize> WithSig<T> {
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "typ", rename = "chat")]
|
#[serde(tag = "typ", rename = "chat")]
|
||||||
pub struct ChatPayload {
|
pub struct ChatPayload {
|
||||||
|
pub rich_text: RichText,
|
||||||
pub room: Uuid,
|
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,
|
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>;
|
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 {
|
macro_rules! impl_u64_flag {
|
||||||
($($name:ident),*) => {
|
($($name:ident),*) => {
|
||||||
$(
|
$(
|
||||||
|
@ -228,11 +420,17 @@ mod sql_impl {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use syn::visit;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Visitor {
|
struct Visitor {
|
||||||
errors: String,
|
errors: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SKIP_CHECK_STRUCTS: &[&str] = &["RichTextPiece", "RichTextPieceRaw"];
|
||||||
|
|
||||||
impl<'ast> syn::visit::Visit<'ast> for Visitor {
|
impl<'ast> syn::visit::Visit<'ast> for Visitor {
|
||||||
fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) {
|
fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) {
|
||||||
let fields = i
|
let fields = i
|
||||||
|
@ -245,6 +443,12 @@ mod tests {
|
||||||
writeln!(self.errors, "unsorted fields: {fields:?}").unwrap();
|
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]
|
#[test]
|
||||||
|
@ -258,4 +462,36 @@ mod tests {
|
||||||
panic!("{}", v.errors);
|
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