A room says what it has hung on its wall

Widgets were nothing here: not read, not listed, not even surfaced as "there
is a thing in this room you cannot open". A room keeping a whiteboard, an
etherpad, a jitsi or a dashboard looked emptier in moho than it actually is.

They are read now - both spellings, because the state event has an old name
and a new one and rooms carry both - and listed beside the pins, which is the
same kind of fact about a room: something it keeps, rather than something said
in it.

Listed rather than drawn, and that is a decision. A widget is somebody else's
web page; this client's whole posture is that content from a room never
reaches a renderer that can navigate. It opens in a browser, where somebody
else's page belongs.

Three details that are easy to get wrong and quiet when wrong:

A widget is taken down by replacing its content with `{}`, because state
events cannot be deleted - so empty content is a removal, not a widget with
nothing in it. Read the other way, a removed widget stays on the wall for
ever.

The url is templated. Element fills `$matrix_user_id` and friends before
opening one, so a link handed over with the variable still in it 404s. The
ones this client can fill are filled; the ones needing a scalar token from an
integration manager there is none of are left alone, and that widget says it
needs one instead of offering a link that does not work.

The list is kept by widget id and comes back in a stable order. A list that
reshuffles itself every time a room's state is re-read is a list nobody can
point at.

Not verified against a live widget: none of the three rooms on the working
account has one, and putting one in a room with other people in it to watch it
arrive is not a test worth running. Verified against Element's shape, and
through the whole path - event read, kept by id, replaced in place, removed on
empty content, ordered.

Closes #191.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 05:54:44 -04:00
co-authored by Claude Opus 5
parent e15b964352
commit bb33747609
5 changed files with 320 additions and 5 deletions
+1
View File
@@ -81,6 +81,7 @@ pub mod polls;
pub mod calls;
pub mod protocol;
pub mod roomstate;
pub mod widgets;
pub mod sliding;
pub mod dehydration;
pub mod ssss;
+25 -1
View File
@@ -22,7 +22,7 @@
//! cached: the joined-member roster, power levels (sort key), and presence
//! (online/offline split) - called any time one of those three changes.
use super::{cached_media_path, moderation, protocol};
use super::{cached_media_path, moderation, protocol, widgets};
use crate::model;
use crate::state::AppState;
use serde_json::Value;
@@ -42,6 +42,30 @@ pub async fn process_state_events(state: &AppState, account_id: &str, room_id: &
// messages themselves are looked up when somebody asks to see
// them, because a pin can point at something said years before
// this client ever joined.
// Whatever the room has hung on its wall: a jitsi, an etherpad,
// a whiteboard, a dashboard. Two spellings because the state
// event has an old name and a new one and rooms carry both -
// Element writes the `im.vector.modular.widgets` form to this
// day, and reads either.
//
// The state key is the widget's id, and content going empty is
// how one is taken down: a widget is removed by replacing it
// with `{}`, because state events cannot be deleted.
"im.vector.modular.widgets" | "m.widget" => {
let Some(id) = event["state_key"].as_str().filter(|s| !s.is_empty()) else { continue };
let widget = widgets::read(id, &event["content"]);
state.runtime.set_matrix_widget(account_id, room_id, id, widget);
if let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, room_id) {
state.events.emit(
"matrixWidgets",
serde_json::json!({
"bufferId": buffer_id,
"widgets": state.runtime.matrix_widgets(account_id, room_id),
}),
);
}
}
"m.room.pinned_events" => {
let pinned: Vec<String> = event["content"]["pinned"]
.as_array()
+191
View File
@@ -0,0 +1,191 @@
//! What a room has hung on its wall.
//!
//! A Matrix room can carry widgets: a jitsi conference, an etherpad, a
//! whiteboard, a dashboard somebody wrote. They are state events, so they are
//! part of what the room *is* rather than something said in it - and a room
//! that keeps one showed nothing of it here at all, which made the room look
//! emptier than it was.
//!
//! This reads them and says what is there. It does not draw them, and that is
//! a decision rather than an omission: a widget is somebody else's web page,
//! and the client's whole security posture is that a message body never
//! reaches a renderer that can navigate (see the sanitiser in richtext, and
//! the captcha window's own comment on why *that* one gets a window of its
//! own). Opening one belongs in a browser, where a page from a room's
//! integration manager is somebody else's page in somebody else's sandbox.
//!
//! The url is templated. Element fills `$matrix_user_id`, `$matrix_room_id`
//! and friends before opening one, and a link handed over with the braces
//! still in it is a link that 404s - so the substitutions a widget can
//! actually be opened with are done here, and the ones that need a scalar
//! token are deliberately not.
use serde_json::{json, Value};
/// One widget, as a client needs it.
///
/// Kept as JSON rather than a struct because this crosses the wire almost
/// unchanged and has no behaviour - and because a widget's `data` is whatever
/// its author put there.
pub(super) fn read(id: &str, content: &Value) -> Option<Value> {
// A widget is taken down by replacing its content with `{}` - state
// events cannot be deleted - so an empty one is a removal, not a widget
// with nothing in it.
let url = content["url"].as_str().filter(|s| !s.is_empty())?;
let kind = content["type"].as_str().unwrap_or("m.custom");
Some(json!({
"id": id,
"kind": kind,
// What to call it in a list. Widgets are often unnamed, and "Etherpad"
// is a better answer than an empty row.
"name": content["name"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| pretty_kind(kind)),
"url": url,
"creator": content["creatorUserId"].as_str().unwrap_or(""),
// Whether a link can actually be opened, which is not the same as
// whether there is a url - see `fill`.
"openable": !url.contains("$matrix_") || fillable(url),
"data": content["data"].clone(),
}))
}
/// A name for a widget that did not bring one.
fn pretty_kind(kind: &str) -> String {
match kind {
"jitsi" | "m.jitsi" => "Jitsi call".to_string(),
"etherpad" | "m.etherpad" => "Etherpad".to_string(),
"whiteboard" | "m.whiteboard" => "Whiteboard".to_string(),
"grafana" => "Grafana".to_string(),
"video" | "m.video" => "Video".to_string(),
"m.custom" | "" => "Widget".to_string(),
other => other.to_string(),
}
}
/// The variables a widget url can carry, and what this client can put in them.
///
/// Element substitutes a longer list, several of which need a scalar token
/// from an integration manager this client does not have. Those are left
/// alone and the widget is reported as not openable, which is honest: a link
/// with `$matrix_display_name` still in it does not open, and offering it
/// anyway is offering a broken link.
const FILLABLE: [&str; 3] = ["$matrix_user_id", "$matrix_room_id", "$matrix_widget_id"];
fn fillable(url: &str) -> bool {
// Every variable the url mentions has to be one we can fill.
url.match_indices("$matrix_")
.all(|(at, _)| FILLABLE.iter().any(|v| url[at..].starts_with(v)))
}
/// Puts this account and room into a widget's url.
pub fn fill(url: &str, user_id: &str, room_id: &str, widget_id: &str) -> String {
url.replace("$matrix_user_id", &encode(user_id))
.replace("$matrix_room_id", &encode(room_id))
.replace("$matrix_widget_id", &encode(widget_id))
}
fn encode(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_widget_is_read_with_a_name_it_can_be_listed_under() {
let w = read(
"widget_1",
&json!({ "type": "etherpad", "url": "https://pad.example/p/notes", "creatorUserId": "@a:b" }),
)
.expect("a widget");
assert_eq!(w["kind"], "etherpad");
// Unnamed, so named for what it is rather than left blank.
assert_eq!(w["name"], "Etherpad");
assert_eq!(w["creator"], "@a:b");
assert_eq!(w["openable"], true);
let named = read("w2", &json!({ "type": "m.custom", "url": "https://x/y", "name": "Rota" })).unwrap();
assert_eq!(named["name"], "Rota");
}
/// A widget is taken down by emptying its content, because state events
/// cannot be deleted. Reading that back as a widget would leave a removed
/// one on screen for ever.
#[test]
fn an_emptied_widget_is_a_removed_widget() {
assert!(read("w", &json!({})).is_none());
assert!(read("w", &json!({ "type": "etherpad" })).is_none());
assert!(read("w", &json!({ "url": "" })).is_none());
}
/// A url still holding a variable this client cannot fill does not open,
/// and saying it does is offering a broken link.
#[test]
fn a_url_is_only_openable_when_every_variable_can_be_filled() {
let ours = read("w", &json!({ "url": "https://x/?room=$matrix_room_id&me=$matrix_user_id" })).unwrap();
assert_eq!(ours["openable"], true);
let theirs = read("w", &json!({ "url": "https://x/?t=$matrix_display_name" })).unwrap();
assert_eq!(theirs["openable"], false, "a variable we cannot fill must not be called openable");
}
/// The path a widget actually takes: a state event is read, kept by id,
/// replaced when it changes, and removed when its content is emptied -
/// with the list coming back in a stable order, because a list that
/// reshuffles itself whenever a room's state is re-read is one nobody can
/// point at.
#[test]
fn a_room_keeps_its_wall_in_order() {
let runtime = crate::runtime::Runtime::new();
let (account, room) = ("matrix:@salastil:poa.st", "!abc:poa.st");
for (id, content) in [
("b_pad", json!({ "type": "etherpad", "url": "https://pad/x", "name": "Notes" })),
("a_call", json!({ "type": "jitsi", "url": "https://jitsi/y" })),
] {
runtime.set_matrix_widget(account, room, id, read(id, &content));
}
let wall = runtime.matrix_widgets(account, room);
assert_eq!(wall.len(), 2);
// By id, and the same order every time.
assert_eq!(wall[0]["id"], "a_call");
assert_eq!(wall[0]["name"], "Jitsi call");
assert_eq!(wall[1]["name"], "Notes");
// Changed in place rather than added twice.
runtime.set_matrix_widget(account, room, "b_pad", read("b_pad", &json!({ "type": "etherpad", "url": "https://pad/x", "name": "Minutes" })));
let wall = runtime.matrix_widgets(account, room);
assert_eq!(wall.len(), 2);
assert_eq!(wall[1]["name"], "Minutes");
// Taken down: the event arrives with empty content, `read` gives
// nothing, and the widget goes.
runtime.set_matrix_widget(account, room, "b_pad", read("b_pad", &json!({})));
let wall = runtime.matrix_widgets(account, room);
assert_eq!(wall.len(), 1);
assert_eq!(wall[0]["id"], "a_call");
// And a room nobody hung anything in has an empty wall, not an error.
assert!(runtime.matrix_widgets(account, "!other:poa.st").is_empty());
}
#[test]
fn filling_a_url_escapes_what_it_puts_in() {
let filled = fill(
"https://pad.example/?room=$matrix_room_id&me=$matrix_user_id&w=$matrix_widget_id",
"@salastil:poa.st",
"!abc:example.org",
"widget 1",
);
assert_eq!(
filled,
"https://pad.example/?room=%21abc%3Aexample.org&me=%40salastil%3Apoa.st&w=widget+1"
);
// Nothing to fill is not an error.
assert_eq!(fill("https://x/y", "@a:b", "!c:d", "w"), "https://x/y");
}
}
+37
View File
@@ -4218,6 +4218,43 @@ pub async fn dispatch(
// A room's own name and topic. Two state events with one shape, which
// is why they share a method rather than having one each.
// What a room has hung on its wall: a jitsi, an etherpad, a
// whiteboard, a dashboard somebody wrote.
//
// Answered with the url already filled in, because the caller has no
// business knowing that a widget url carries $matrix_ variables - and
// because filling them needs the account's own id, which is here and
// not there.
"listMatrixWidgets" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listMatrixWidgets requires \"bufferId\"".to_string()));
};
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such buffer".to_string()));
};
let Some(room_id) = state.runtime.get_matrix_room(buffer_id) else {
return (Some(serde_json::json!([])), None);
};
let user_id = state
.accounts
.get_matrix(&buffer.account_id)
.map(|a| a.user_id)
.unwrap_or_default();
let filled: Vec<serde_json::Value> = state
.runtime
.matrix_widgets(&buffer.account_id, &room_id)
.into_iter()
.map(|mut widget| {
if let (Some(url), Some(id)) = (widget["url"].as_str(), widget["id"].as_str()) {
let opened = backend::matrix::widgets::fill(url, &user_id, &room_id, id);
widget["url"] = serde_json::json!(opened);
}
widget
})
.collect();
(Some(serde_json::json!(filled)), None)
}
// The account's own stickers, from the packs it carries and the ones
// its rooms share.
"listMatrixStickers" => {
+66 -4
View File
@@ -691,6 +691,12 @@ pub struct Runtime {
/// server, and a pin outliving the message it points at is a normal state
/// rather than a broken one.
matrix_pinned: Mutex<HashMap<(String, String), Vec<String>>>,
/// (account, room) -> what the room has hung on its wall, by widget id.
///
/// Kept by id rather than as a list because a widget is a state event:
/// what arrives is one of them changing, and it is removed by its content
/// being emptied rather than by anything saying it went away.
matrix_widgets: Mutex<HashMap<(String, String), std::collections::BTreeMap<String, serde_json::Value>>>,
/// Matrix-specific: (account id, room id) -> {user id -> display name}
/// for every member currently *joined* to that room (see roomstate.rs's
/// m.room.member handling - a leave/ban removes the entry entirely,
@@ -1018,6 +1024,7 @@ impl Runtime {
matrix_room_avatars: Mutex::new(HashMap::new()),
matrix_power_levels: Mutex::new(HashMap::new()),
matrix_pinned: Mutex::new(HashMap::new()),
matrix_widgets: Mutex::new(HashMap::new()),
matrix_verification_peers: Mutex::new(HashMap::new()),
matrix_ignored: Mutex::new(HashMap::new()),
matrix_room_members: Mutex::new(HashMap::new()),
@@ -2654,6 +2661,32 @@ impl Runtime {
self.matrix_verification_peers.lock().unwrap().get(account_id).cloned().unwrap_or_default()
}
/// Records a widget, or takes it down when its content was emptied.
pub fn set_matrix_widget(&self, account_id: &str, room_id: &str, widget_id: &str, widget: Option<serde_json::Value>) {
let mut held = self.matrix_widgets.lock().unwrap();
let room = held.entry((account_id.to_string(), room_id.to_string())).or_default();
match widget {
Some(widget) => {
room.insert(widget_id.to_string(), widget);
}
None => {
room.remove(widget_id);
}
}
}
/// What a room is carrying, in a stable order - a list that reshuffles
/// itself every time a room's state is re-read is a list nobody can point
/// at.
pub fn matrix_widgets(&self, account_id: &str, room_id: &str) -> Vec<serde_json::Value> {
self.matrix_widgets
.lock()
.unwrap()
.get(&(account_id.to_string(), room_id.to_string()))
.map(|room| room.values().cloned().collect())
.unwrap_or_default()
}
pub fn set_matrix_pinned(&self, account_id: &str, room_id: &str, events: Vec<String>) {
self.matrix_pinned.lock().unwrap().insert((account_id.to_string(), room_id.to_string()), events);
}
@@ -4504,9 +4537,11 @@ impl Runtime {
/// added again in the same session, which is exactly when somebody is
/// most likely to be trying to fix something.
///
/// Swept rather than enumerated, on the rule in `belongs_to` above, so a
/// map added later is covered by this the day it is added rather than the
/// day somebody remembers to add it here.
/// Swept on the rule in `belongs_to` above rather than by knowing what
/// each map means, which is what makes seventy-odd of them tractable: the
/// rule never needs revisiting, only the list of fields does. That list is
/// not automatic - a map added to this struct has to be added here too, or
/// it is the one thing an account leaves behind.
pub fn forget_account(&self, account_id: &str) {
macro_rules! sweep {
($($field:ident),* $(,)?) => {
@@ -4549,7 +4584,7 @@ impl Runtime {
matrix_room_avatars, matrix_power_levels, matrix_pinned,
matrix_room_members, matrix_read_receipts, sneedchat_motds,
irc_whois, discord_mutes, matrix_presence,
matrix_own_reactions, irc_splits,
matrix_own_reactions, irc_splits, matrix_widgets,
);
}
}
@@ -4558,6 +4593,33 @@ impl Runtime {
mod forget_tests {
use super::belongs_to;
/// Every map in the struct has to be in the sweep, and the list is
/// hand-written - so this reads both and says which was forgotten.
///
/// Source-reading in a test is unusual and earns its place here: the cost
/// of a missing field is an account that leaves something behind, and the
/// failure is silent in exactly the way the whole change exists to stop.
#[test]
fn every_map_is_swept_when_an_account_is_forgotten() {
let src = include_str!("runtime.rs");
let start = src.find("pub struct Runtime {").expect("the struct");
let end = src[start..].find("\n}").expect("its end") + start;
let fields: Vec<&str> = src[start..end]
.lines()
.filter_map(|line| {
let line = line.trim_start();
let name = line.split(": Mutex<HashMap<").next()?;
(line.contains(": Mutex<HashMap<") && !name.contains(' ')).then_some(name)
})
.collect();
assert!(fields.len() > 70, "found only {} maps - the parse is wrong", fields.len());
let sweep = src.find("pub fn forget_account").expect("the sweep");
let sweep_body = &src[sweep..sweep + 4000];
let missing: Vec<&&str> = fields.iter().filter(|f| !sweep_body.contains(**f)).collect();
assert!(missing.is_empty(), "these maps would survive removing an account: {missing:?}");
}
/// The whole of forgetting an account rests on this one test, so it is
/// worth being explicit about what it must and must not match.
#[test]