Matrix emoji, and a contract that does not care which service is next

Two things #205 needed that the first pass did not have.

Matrix was missing because `is_sticker` threw its emoticons away. An
`im.ponies` pack carries both in one map and they are genuinely different -
a sticker is an event of its own, an emoticon is an image inside a line of
text - so the pack now feeds two places: the sticker picker that already
existed, and this. A pack that states no usage means both, which is the
spec's own default and is tested rather than assumed.

The larger change is `kind` on a source, which is what stops the picker
needing to know the services by name. `"text"` means the entry's id is
written into the message being composed - Discord, Kick, Sneedchat, 7TV and
Matrix emoticons all work that way, differing only in what that run of
characters looks like. `"event"` means it is sent on its own and never
reaches the composer, which is what a Matrix sticker is.

That is the whole contract. A backend calls `set_emoji_source` and the thing
appears, with its own section and its own icon, in every picker: nothing to
register, no list of services to extend, no change to the window. The one
decision a new service has to make is what goes in `EmojiEntry::id`, since
for a text source it is written into the message verbatim and so has to
already be in the form that service reads.

Towards #205.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 12:32:36 -04:00
co-authored by Claude Opus 5
parent c74ff95451
commit 197cfd3668
6 changed files with 128 additions and 1 deletions
+1
View File
@@ -445,6 +445,7 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
id: guild_id.to_string(),
name: guild_name.clone(),
service: "discord".to_string(),
kind: "text".to_string(),
icon_url: cached_guild_icon(guild_id, guild["icon"].as_str()).await,
// Named by channel below, once the visible set is known.
buffers: Vec::new(),
+1
View File
@@ -509,6 +509,7 @@ async fn prepare(
id: format!("kick:{slug}"),
name: slug.clone(),
service: "kick".to_string(),
kind: "text".to_string(),
icon_url: avatar_url.clone(),
buffers: vec![buffer_id.clone()],
sendable_anywhere: subscribed,
+64 -1
View File
@@ -42,14 +42,56 @@ pub struct Sticker {
/// a pack of images usable either way, and dropping those would empty most of
/// the packs people actually have.
fn is_sticker(image: &Value, pack_usage: &Value) -> bool {
has_usage(image, pack_usage, "sticker")
}
/// Whether an image in a pack is offered for one use.
///
/// A pack states a usage and an image may override it; either may say both,
/// and saying nothing means both. `im.ponies` packs carry stickers and
/// emoticons in the same map, and they are different things: a sticker is an
/// event of its own, an emoticon is an image inside a line of text.
fn has_usage(image: &Value, pack_usage: &Value, want: &str) -> bool {
let usage = image.get("usage").filter(|u| u.is_array()).unwrap_or(pack_usage);
match usage.as_array() {
None => true,
Some(list) if list.is_empty() => true,
Some(list) => list.iter().any(|u| u.as_str() == Some("sticker")),
Some(list) => list.iter().any(|u| u.as_str() == Some(want)),
}
}
/// The emoticons in a pack - the half `read_pack` leaves behind.
///
/// Separate rather than a flag on `read_pack`, because the two are used for
/// different things and confusing them shows: a sticker sent as an emoticon is
/// a full-size image in the middle of a sentence, and an emoticon sent as a
/// sticker is a twenty-pixel picture posted on its own.
pub fn read_emoticons(content: &Value, fallback_name: &str) -> Vec<Sticker> {
let pack_name = content["pack"]["display_name"]
.as_str()
.filter(|n| !n.trim().is_empty())
.unwrap_or(fallback_name)
.to_string();
let pack_usage = content["pack"]["usage"].clone();
let Some(images) = content["images"].as_object() else { return Vec::new() };
images
.iter()
.filter(|(_, image)| has_usage(image, &pack_usage, "emoticon"))
.filter_map(|(shortcode, image)| {
let mxc = image["url"].as_str()?;
if !mxc.starts_with("mxc://") {
return None;
}
Some(Sticker {
name: shortcode.clone(),
pack: pack_name.clone(),
mxc: mxc.to_string(),
body: image["body"].as_str().filter(|b| !b.trim().is_empty()).unwrap_or(shortcode).to_string(),
})
})
.collect()
}
/// Reads one `im.ponies.*` pack into the stickers it offers.
///
/// Tolerant on purpose: these are written by half a dozen different bots and
@@ -266,6 +308,27 @@ mod tests {
})
}
/// 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.
#[test]
fn the_same_pack_offers_its_emoticons_to_whoever_asks_for_those() {
let pack = serde_json::json!({
"pack": { "display_name": "mixed" },
"images": {
"big": { "url": "mxc://x/1", "usage": ["sticker"] },
"small": { "url": "mxc://x/2", "usage": ["emoticon"] },
"both": { "url": "mxc://x/3" }
}
});
let stickers: Vec<String> = read_pack(&pack, "f").into_iter().map(|s| s.name).collect();
let emoticons: Vec<String> = read_emoticons(&pack, "f").into_iter().map(|s| s.name).collect();
assert!(stickers.contains(&"big".to_string()) && !stickers.contains(&"small".to_string()));
assert!(emoticons.contains(&"small".to_string()) && !emoticons.contains(&"big".to_string()));
// Saying nothing means both, which is what the spec's default is.
assert!(stickers.contains(&"both".to_string()) && emoticons.contains(&"both".to_string()));
}
#[test]
fn a_pack_offers_its_stickers_and_not_its_emoticons() {
let mut read = read_pack(&pack(), "fallback");
+34
View File
@@ -500,6 +500,40 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
// pack lives.
if event["type"].as_str() == Some("im.ponies.user_emotes") {
state.runtime.set_matrix_sticker_pack(account_id, "", "your stickers", &event["content"]);
// The other half of the same pack. Stickers are events of
// their own and have their own picker; emoticons are images
// 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");
if !emoticons.is_empty() {
state.runtime.set_emoji_source(
account_id,
crate::model::EmojiSource {
id: "matrix:user".to_string(),
name: emoticons[0].pack.clone(),
service: "matrix".to_string(),
kind: "text".to_string(),
icon_url: None,
// Account data, so it travels with the account and
// is sendable in every room it can talk in.
buffers: Vec::new(),
sendable_anywhere: true,
emoji: emoticons
.iter()
.map(|e| crate::model::EmojiEntry {
// The shortcode, which is what goes in the
// message - the send path turns it into
// the image on the way out.
id: format!(":{}:", e.name),
name: e.name.clone(),
url: Some(e.mxc.clone()),
animated: false,
locked: false,
})
.collect(),
},
);
}
}
}
}
+27
View File
@@ -6,12 +6,39 @@ use serde::{Deserialize, Serialize};
/// A source rather than a flat list because the picker is organised by where
/// things came from - that is how somebody finds an emote they half remember,
/// and it is the only way to say *why* one cannot be sent here.
///
/// ## Adding one
///
/// A backend calls `Runtime::set_emoji_source` with one of these and it
/// appears in every picker, with its own section and its own icon in the jump
/// strip. There is nothing to register, no list of services to extend, and no
/// change to the window: a source says where it can be sent, what choosing one
/// does (`kind`), and which of its entries are locked, and that is everything
/// the picker asks of it.
///
/// The one thing a new service must decide is what goes in `EmojiEntry::id` -
/// it is written into the message verbatim for a `"text"` source, so it has to
/// already be in whatever form that service reads.
#[derive(Serialize, Clone, Debug)]
pub struct EmojiSource {
/// Unique within an account. A Discord guild id, a Kick channel slug.
pub id: String,
pub name: String,
pub service: String,
/// What choosing one of these does, which is not the same everywhere and
/// is the thing a picker cannot guess.
///
/// `"text"` - the entry's `id` is written into the message being composed.
/// Discord, Kick, Sneedchat and 7TV all work this way: what is sent is a
/// run of characters the service recognises.
///
/// `"event"` - the entry is sent on its own, immediately, and never
/// appears in the composer. A Matrix sticker is this.
///
/// Adding a service means adding a source with one of these; nothing in
/// the picker needs to learn its name. That is the whole of the contract -
/// see `EmojiSource`'s own comment.
pub kind: String,
/// The guild's icon or the channel's avatar, for the jump strip.
#[serde(rename = "iconUrl", skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
+1
View File
@@ -118,6 +118,7 @@ pub async fn dispatch(
id: "sneedchat".to_string(),
name: "Sneedchat".to_string(),
service: "sneedchat".to_string(),
kind: "text".to_string(),
icon_url: None,
buffers: Vec::new(),
sendable_anywhere: true,