Stickers, voice messages and places

The rest of what Element sends and this backend dropped.

A sticker is an image that arrives under its own event type rather than as
a message with a msgtype, so it was failing the type gate and vanishing.
Given the msgtype it lacks, the whole media path already here - fetch,
cache, describe - reads it as the image it is.

A voice message is an audio file with a note saying it was spoken. It said
"Voice message.ogg" and now says what it is; the audio itself was already
arriving as an attachment.

A location is the one that cannot be drawn honestly: `geo:52.2,0.12` means
nothing to a person, so it becomes what the sender called it plus a link to
a map that can show the point. Drawing the map here would mean fetching
tiles from somebody else's server for every message mentioning a street
corner.

Both readers are tested, including the geo URI with a precision suffix and
the audio file that is not a voice message.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 18:05:54 -04:00
co-authored by Claude Opus 5
parent 4f306c2192
commit d3ce600887
2 changed files with 92 additions and 2 deletions
+24 -2
View File
@@ -688,6 +688,7 @@ async fn handle_timeline_event(
if outer_type != protocol::EVENT_ROOM_MESSAGE
&& outer_type != protocol::EVENT_ROOM_ENCRYPTED
&& outer_type != protocol::EVENT_REACTION
&& outer_type != protocol::EVENT_STICKER
// A poll is three kinds of event and none of them is a message.
&& !polls::is_poll_event(outer_type)
{
@@ -719,7 +720,7 @@ async fn handle_timeline_event(
// reaction can be encrypted too, not just messages) - so the
// effective type/content used below come from the decrypted envelope
// when there is one, the outer event otherwise.
let (effective_type, content, undecryptable): (String, Value, bool) = if outer_type == protocol::EVENT_ROOM_ENCRYPTED {
let (effective_type, mut content, undecryptable): (String, Value, bool) = if outer_type == protocol::EVENT_ROOM_ENCRYPTED {
match decrypt_event(session, event, room_id).await {
Ok(decrypted) => {
let t = decrypted["type"].as_str().unwrap_or(protocol::EVENT_ROOM_MESSAGE).to_string();
@@ -757,7 +758,15 @@ async fn handle_timeline_event(
return;
}
if effective_type != protocol::EVENT_ROOM_MESSAGE {
// A sticker is an image that arrived under its own event type rather
// than as a message with a msgtype. Given the msgtype it is missing, the
// whole media path below - fetch, cache, describe - reads it as what it
// is instead of dropping it for having the wrong envelope.
if effective_type == protocol::EVENT_STICKER {
if let Some(object) = content.as_object_mut() {
object.entry("msgtype").or_insert_with(|| Value::from("m.image"));
}
} else if effective_type != protocol::EVENT_ROOM_MESSAGE {
return;
}
@@ -796,6 +805,12 @@ async fn handle_timeline_event(
let path = cached_encrypted_media_path(homeserver_url, access_token, file, ext).await;
attachments.push(build_attachment(&content, path, thumbnail_for(&content, homeserver_url, access_token).await));
(protocol::message_body(&content).0, false)
} else if let Some(place) = protocol::location_of(&content) {
// A place, as the one thing a chat client can honestly do with one:
// what the sender called it, and a link to a map that can show it.
// Drawing the map here would mean shipping tiles from somebody's
// server on every message that mentions a street corner.
(place, false)
} else {
protocol::message_body(&content)
};
@@ -851,6 +866,13 @@ async fn handle_timeline_event(
}
}
// A voice message is an audio file with a note on it saying it was
// spoken rather than sent. Element shows it as a waveform; this at least
// stops it reading as somebody attaching "Voice message.ogg".
if protocol::is_voice_message(&content) {
body = "Voice message".to_string();
}
let sender_avatar_url = state.runtime.get_matrix_member_avatar(account_id, sender);
// The time the server recorded, not the time this arrived. They are the
+68
View File
@@ -16,6 +16,8 @@ pub const EVENT_ROOM_ENCRYPTION: &str = "m.room.encryption";
pub const EVENT_ROOM_ENCRYPTED: &str = "m.room.encrypted";
pub const EVENT_REACTION: &str = "m.reaction";
pub const EVENT_REDACTION: &str = "m.room.redaction";
/// A sticker: an image under its own event type rather than a msgtype.
pub const EVENT_STICKER: &str = "m.sticker";
pub fn event_type(event: &Value) -> &str {
event["type"].as_str().unwrap_or("")
@@ -56,6 +58,38 @@ pub fn message_body(content: &Value) -> (String, bool) {
(body, is_action)
}
/// Whether this is a voice message rather than an attached audio file.
///
/// The marker is still under its MSC prefix everywhere that sends it, and the
/// stable name is read too so this keeps working when that lands.
pub fn is_voice_message(content: &Value) -> bool {
content["msgtype"].as_str() == Some("m.audio")
&& (content.get("org.matrix.msc3245.voice").is_some() || content.get("m.voice").is_some())
}
/// A location message, as a line somebody can act on.
///
/// `geo_uri` is "geo:lat,long" and means nothing to a person; the description
/// beside it usually does. Both are given, with a link to a map that can show
/// the point - pointing at one is honest, drawing one would mean fetching
/// tiles from somebody's server for every message that mentions a place.
pub fn location_of(content: &Value) -> Option<String> {
if content["msgtype"].as_str() != Some("m.location") {
return None;
}
let geo = content["geo_uri"].as_str()?;
let point = geo.strip_prefix("geo:")?.split(';').next()?;
let (lat, long) = point.split_once(',')?;
let described = content["body"].as_str().filter(|b| !b.is_empty()).unwrap_or("Location");
Some(format!(
"{described} — https://www.openstreetmap.org/?mlat={}&mlon={}#map=16/{}/{}",
lat.trim(),
long.trim(),
lat.trim(),
long.trim()
))
}
/// The sender's formatted version of the body, if there is one.
///
/// Only `org.matrix.custom.html` counts: the spec allows other formats and a
@@ -197,6 +231,40 @@ pub fn encrypted_media_file(content: &Value) -> Option<&Value> {
#[cfg(test)]
mod tests {
use super::*;
/// A place, in the only form a chat client can honestly show one.
#[test]
fn reads_a_location_as_something_to_press() {
let content = serde_json::json!({
"msgtype": "m.location",
"body": "The pub",
"geo_uri": "geo:52.2053,0.1218"
});
let line = location_of(&content).expect("a location");
assert!(line.starts_with("The pub — https://www.openstreetmap.org/?mlat=52.2053&mlon=0.1218"), "{line}");
// A geo URI with a precision suffix is still a point.
let precise = serde_json::json!({ "msgtype": "m.location", "geo_uri": "geo:52.2,0.12;u=35" });
assert!(location_of(&precise).expect("a location").starts_with("Location — "));
// Anything else is not a location and must not be read as one.
assert_eq!(location_of(&serde_json::json!({ "msgtype": "m.text", "body": "geo:1,2" })), None);
}
/// A voice message is an audio file with a note saying it was spoken.
#[test]
fn tells_a_voice_message_from_an_audio_file() {
assert!(is_voice_message(&serde_json::json!({
"msgtype": "m.audio",
"org.matrix.msc3245.voice": {}
})));
// The stable name, for when it lands.
assert!(is_voice_message(&serde_json::json!({ "msgtype": "m.audio", "m.voice": {} })));
// An ordinary attached song is not a voice message.
assert!(!is_voice_message(&serde_json::json!({ "msgtype": "m.audio" })));
}
use super::formatted_body;
use serde_json::json;