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