7TV in the picker, and a Matrix shortcode that actually sends
The two loose ends #205 was left with. 7TV is a source now. Its id is the word itself, because that is literally what goes in the message - and it is tied to its buffer, because a 7TV emote is a word that happens to be a picture and means nothing in a channel whose set does not have it. Matrix emoticons were registered as `:shortcode:` with nothing to turn that into an image, so picking one would have inserted text that sent as literal colons. `inline_emoticons` does the conversion on the way out, into the formatted body only - `:shortcode:` in the plain body is exactly the fallback a client with no images should show, and rewriting it there would leave those clients reading an `<img>` tag as words. `data-mx-emoticon` is what makes a receiving client draw it at line height rather than full size, and is the shape Element sends. Matched with its colons, unlike 7TV where the bare word is the emote: a Matrix emoticon is written deliberately, so "the cat sat" is a sentence and `:cat:` is a picture. A closing colon has to be on the same run of non-whitespace, or "look: a thing" would go hunting through the message for a partner and swallow everything between - which is tested, along with "12:30", two in a row, and an account with no pack at all. Towards #205. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -401,6 +401,8 @@ async fn prepare(
|
||||
let http = http.clone();
|
||||
let state = state.clone();
|
||||
let buffer_id = buffer.id.clone();
|
||||
let account_id = account_id.to_string();
|
||||
let slug = channel.slug.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut words = seventv::global(&http).await;
|
||||
// The channel's own on top, so a channel that overrides a global
|
||||
@@ -408,6 +410,33 @@ async fn prepare(
|
||||
words.extend(seventv::for_channel(&http, user_id).await);
|
||||
if !words.is_empty() {
|
||||
tracing::debug!("kick[{buffer_id}]: {} 7TV emote(s)", words.len());
|
||||
// Also a place emoji come from, so the picker offers them.
|
||||
// The id is the word itself, because that is literally what
|
||||
// goes in the message - a 7TV emote is a word that happens to
|
||||
// be a picture, which is why it is tied to this buffer: it
|
||||
// means nothing in a channel whose set does not have it.
|
||||
state.runtime.set_emoji_source(
|
||||
&account_id,
|
||||
crate::model::EmojiSource {
|
||||
id: format!("7tv:{slug}"),
|
||||
name: format!("{slug} · 7TV"),
|
||||
service: "kick".to_string(),
|
||||
kind: "text".to_string(),
|
||||
icon_url: None,
|
||||
buffers: vec![buffer_id.clone()],
|
||||
sendable_anywhere: false,
|
||||
emoji: words
|
||||
.iter()
|
||||
.map(|(word, url)| crate::model::EmojiEntry {
|
||||
id: word.clone(),
|
||||
name: word.clone(),
|
||||
url: Some(url.clone()),
|
||||
animated: false,
|
||||
locked: false,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
state.runtime.set_kick_word_emotes(&state, &buffer_id, words);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -567,7 +567,24 @@ pub async fn send_message(
|
||||
// The formatting somebody typed, where they typed any. Sent
|
||||
// alongside the plain text rather than instead of it: body stays the
|
||||
// fallback for a client that will not render HTML.
|
||||
if let Some(html) = markup::to_html(body) {
|
||||
// The formatting somebody typed, plus any emoji they picked. The two
|
||||
// together, because an emoticon is only an emoticon in the formatted
|
||||
// half: `:shortcode:` in the plain body is exactly the fallback a
|
||||
// client with no images should show, and replacing it there would
|
||||
// leave those clients reading an `<img>` tag as words.
|
||||
let typed = markup::to_html(body);
|
||||
let emoji = state.runtime.matrix_emoticons(account_id);
|
||||
let html = match typed {
|
||||
Some(html) => Some(stickers::inline_emoticons(&html, &emoji)),
|
||||
// Nothing was typed in markup, but an emoticon still needs a
|
||||
// formatted body to live in - so one is made only when there is
|
||||
// actually an emoticon to put in it.
|
||||
None => {
|
||||
let inlined = stickers::inline_emoticons(&stickers::escape_html(body), &emoji);
|
||||
(inlined != stickers::escape_html(body)).then_some(inlined)
|
||||
}
|
||||
};
|
||||
if let Some(html) = html {
|
||||
content["format"] = serde_json::json!("org.matrix.custom.html");
|
||||
content["formatted_body"] = serde_json::json!(html);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,68 @@ pub fn read_pack(content: &Value, fallback_name: &str) -> Vec<Sticker> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Escapes text so it can sit inside a formatted body.
|
||||
pub fn escape_html(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turns `:shortcode:` into the image it stands for, inside a formatted body.
|
||||
///
|
||||
/// `data-mx-emoticon` is what marks an image as an emoticon rather than an
|
||||
/// attachment, and it is why a receiving client draws it at line height
|
||||
/// instead of full size. Element sends exactly this shape.
|
||||
///
|
||||
/// Only in the formatted half, never in the plain body: `:shortcode:` there is
|
||||
/// precisely the fallback a client with no images should show, and rewriting
|
||||
/// it would leave those clients reading an `<img>` tag as words.
|
||||
///
|
||||
/// The shortcode is matched with its colons, so a bare word that happens to
|
||||
/// share a name with an emoticon is left alone - unlike 7TV, where the bare
|
||||
/// word *is* the emote, Matrix's are written deliberately.
|
||||
pub fn inline_emoticons(html: &str, emoticons: &std::collections::BTreeMap<String, String>) -> String {
|
||||
if emoticons.is_empty() || !html.contains(':') {
|
||||
return html.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut rest = html;
|
||||
while let Some(start) = rest.find(':') {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start + 1..];
|
||||
// The closing colon, on the same line: a colon at the end of a
|
||||
// sentence should not go hunting through the whole message for a
|
||||
// partner.
|
||||
let end = after.find(':').filter(|e| !after[..*e].contains(char::is_whitespace));
|
||||
match end.and_then(|e| emoticons.get(&after[..e]).map(|mxc| (e, mxc))) {
|
||||
Some((e, mxc)) => {
|
||||
let name = &after[..e];
|
||||
out.push_str(&format!(
|
||||
"<img data-mx-emoticon src=\"{}\" alt=\":{}:\" title=\":{}:\" height=\"32\">",
|
||||
escape_html(mxc),
|
||||
escape_html(name),
|
||||
escape_html(name)
|
||||
));
|
||||
rest = &after[e + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push(':');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The content of an `m.sticker` event for one of these.
|
||||
///
|
||||
/// `info` is left to the caller: a sticker's dimensions are what a client
|
||||
@@ -308,6 +370,50 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn codes() -> std::collections::BTreeMap<String, String> {
|
||||
[("wave".to_string(), "mxc://x/wave".to_string()), ("cat".to_string(), "mxc://x/cat".to_string())]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shortcode_becomes_the_image_it_stands_for() {
|
||||
let got = inline_emoticons("hello :wave: there", &codes());
|
||||
assert!(got.contains("data-mx-emoticon"), "{got}");
|
||||
assert!(got.contains("mxc://x/wave"), "{got}");
|
||||
assert!(got.starts_with("hello ") && got.ends_with(" there"), "{got}");
|
||||
// The fallback a client with no images shows.
|
||||
assert!(got.contains("alt=\":wave:\""), "{got}");
|
||||
}
|
||||
|
||||
/// A colon ending a sentence must not go hunting through the message for
|
||||
/// a partner and swallow everything between.
|
||||
#[test]
|
||||
fn an_ordinary_colon_is_left_alone() {
|
||||
assert_eq!(inline_emoticons("look: a thing", &codes()), "look: a thing");
|
||||
assert_eq!(inline_emoticons("12:30 and :nope: too", &codes()), "12:30 and :nope: too");
|
||||
assert_eq!(inline_emoticons("nothing here", &codes()), "nothing here");
|
||||
}
|
||||
|
||||
/// Unlike 7TV, where the bare word is the emote, Matrix's are written
|
||||
/// deliberately - so a word that merely shares the name is not one.
|
||||
#[test]
|
||||
fn a_bare_word_is_not_an_emoticon() {
|
||||
assert_eq!(inline_emoticons("the cat sat", &codes()), "the cat sat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_in_a_row_both_become_pictures() {
|
||||
let got = inline_emoticons(":wave::cat:", &codes());
|
||||
assert_eq!(got.matches("data-mx-emoticon").count(), 2, "{got}");
|
||||
}
|
||||
|
||||
/// An account with no pack pays nothing and changes nothing.
|
||||
#[test]
|
||||
fn no_emoticons_means_the_text_is_untouched() {
|
||||
assert_eq!(inline_emoticons(":wave:", &Default::default()), ":wave:");
|
||||
}
|
||||
|
||||
/// The same pack, read the other way. One map holds both, and a client
|
||||
/// that confuses them shows a full-size sticker mid-sentence or a
|
||||
/// twenty-pixel emoticon posted on its own.
|
||||
|
||||
@@ -505,6 +505,12 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
|
||||
// inside a line of text, which is what the emoji picker is
|
||||
// for - so the one pack feeds two places (see #205).
|
||||
let emoticons = stickers::read_emoticons(&event["content"], "your emoji");
|
||||
// Also kept by shortcode, which is what the send path needs to
|
||||
// turn `:name:` into the image on the way out.
|
||||
state.runtime.set_matrix_emoticons(
|
||||
account_id,
|
||||
emoticons.iter().map(|e| (e.name.clone(), e.mxc.clone())).collect(),
|
||||
);
|
||||
if !emoticons.is_empty() {
|
||||
state.runtime.set_emoji_source(
|
||||
account_id,
|
||||
|
||||
@@ -457,6 +457,9 @@ pub struct Runtime {
|
||||
/// Whether this account's subscription lets it use emoji away from where
|
||||
/// they live - Discord Nitro. Absent means no.
|
||||
emoji_unrestricted: Mutex<std::collections::HashSet<String>>,
|
||||
/// Matrix emoticons by shortcode, for the send path - see
|
||||
/// backend/matrix/stickers.rs's `inline_emoticons`.
|
||||
matrix_emoticons: Mutex<HashMap<String, std::collections::BTreeMap<String, String>>>,
|
||||
/// Discord-specific: account id -> friends list ({userId, username,
|
||||
/// globalName, avatarUrl, status}), seeded from READY's `relationships`
|
||||
/// (type 1 = friend) + `presences`, kept current by live PRESENCE_UPDATE
|
||||
@@ -975,6 +978,7 @@ impl Runtime {
|
||||
discord_buffer_emojis: Mutex::new(HashMap::new()),
|
||||
emoji_catalogue: Mutex::new(HashMap::new()),
|
||||
emoji_unrestricted: Mutex::new(std::collections::HashSet::new()),
|
||||
matrix_emoticons: Mutex::new(HashMap::new()),
|
||||
discord_friends: Mutex::new(HashMap::new()),
|
||||
sneedchat_senders: Mutex::new(HashMap::new()),
|
||||
kick_senders: Mutex::new(HashMap::new()),
|
||||
@@ -3433,6 +3437,16 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// This account's emoticons, by shortcode - what the send path needs to
|
||||
/// turn `:name:` into the image it stands for.
|
||||
pub fn set_matrix_emoticons(&self, account_id: &str, by_code: std::collections::BTreeMap<String, String>) {
|
||||
self.matrix_emoticons.lock().unwrap().insert(account_id.to_string(), by_code);
|
||||
}
|
||||
|
||||
pub fn matrix_emoticons(&self, account_id: &str) -> std::collections::BTreeMap<String, String> {
|
||||
self.matrix_emoticons.lock().unwrap().get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every sticker this account has, pack by pack.
|
||||
pub fn matrix_stickers(&self, account_id: &str) -> Vec<crate::backend::matrix::stickers::Sticker> {
|
||||
self.matrix_stickers
|
||||
|
||||
Reference in New Issue
Block a user