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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user