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:
2026-09-12 12:42:10 -04:00
co-authored by Claude Opus 5
parent 197cfd3668
commit 2a0e2a95a4
5 changed files with 173 additions and 1 deletions
+29
View File
@@ -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);
}
});
+18 -1
View File
@@ -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);
}
+106
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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.
+6
View File
@@ -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,
+14
View File
@@ -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