See a Matrix invitation, and answer it
process_sync_response read rooms.join and nothing else, so being invited to a room was something this client could not perceive at all: the invite never appeared, and there was no way to accept or decline one. The only route into a room was a join by an id somebody sent you out of band. Invites are read out of sync and held rather than turned into buffers - an invite is not a room you are in, has none of its history, and answering it is a decision somebody has to make. The whole set is replaced each sync and only broadcast when it differs, so one answered elsewhere simply stops being listed. Accepting is an ordinary join and declining an ordinary leave; the server drops the invitation either way, which is what removes it here. rooms.leave is read now too, so a room left from another client or one this account was kicked from stops sitting there looking joined. The inviter is carried alongside the name because a room without one of its own shows as its raw id, and who asked is the part that makes that answerable. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -367,6 +367,41 @@ async fn process_sync_response(state: &AppState, account_id: &str, own_user_id:
|
||||
}
|
||||
}
|
||||
|
||||
// Invitations, which are not rooms you are in. Read before the joined
|
||||
// pass because that one returns early when there is nothing joined -
|
||||
// and an account whose only news this cycle is an invite has exactly
|
||||
// that shape.
|
||||
//
|
||||
// Until this existed, being invited to a room was something the client
|
||||
// could not perceive at all: rooms.invite was never read, so the invite
|
||||
// never appeared, and there was no way to accept or decline one.
|
||||
let invites: Vec<serde_json::Value> = resp["rooms"]["invite"]
|
||||
.as_object()
|
||||
.map(|rooms| {
|
||||
rooms
|
||||
.iter()
|
||||
.map(|(room_id, room)| {
|
||||
let events: Vec<&Value> = room["invite_state"]["events"].as_array().into_iter().flatten().collect();
|
||||
rooms::invite_summary(room_id, own_user_id, &events)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if state.runtime.set_matrix_invites(account_id, invites.clone()) {
|
||||
state.events.emit("matrixInvites", serde_json::json!({ "accountId": account_id, "invites": invites }));
|
||||
}
|
||||
|
||||
// Rooms this account is no longer in - left from another client, or
|
||||
// kicked. Their buffers would otherwise sit there looking joined.
|
||||
if let Some(left) = resp["rooms"]["leave"].as_object() {
|
||||
for room_id in left.keys() {
|
||||
if let Some((buffer_name, _)) = state.runtime.get_matrix_room_name(account_id, room_id) {
|
||||
let buffer_id = crate::model::buffer_id(account_id, &buffer_name);
|
||||
state.runtime.remove_buffer(state, &buffer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(joined) = resp["rooms"]["join"].as_object() else { return };
|
||||
for (room_id, room) in joined {
|
||||
let timeline_events: Vec<&Value> = room["timeline"]["events"].as_array().into_iter().flatten().collect();
|
||||
|
||||
+104
-1
@@ -110,15 +110,118 @@ pub fn room_avatar_mxc(events: &[&Value]) -> Option<String> {
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// What a pending invitation looks like, out of the stripped state Matrix
|
||||
/// sends with it.
|
||||
///
|
||||
/// An invite is not a joined room and has none of its history: the server
|
||||
/// hands over a handful of state events chosen to be enough to decide with -
|
||||
/// usually the room's name and avatar, and the membership event naming who
|
||||
/// invited you. Everything here is best-effort for that reason, and the
|
||||
/// room id is the only field guaranteed to exist.
|
||||
///
|
||||
/// The inviter matters more than it looks. A room with no name shows as its
|
||||
/// own id, which tells nobody anything; "Salastil invited you" is the part
|
||||
/// that makes the decision answerable.
|
||||
pub fn invite_summary(room_id: &str, own_user_id: &str, events: &[&Value]) -> serde_json::Value {
|
||||
let mut name: Option<String> = None;
|
||||
let mut alias: Option<String> = None;
|
||||
let mut avatar: Option<String> = None;
|
||||
let mut inviter: Option<String> = None;
|
||||
let mut is_direct = false;
|
||||
|
||||
for event in events {
|
||||
match event["type"].as_str().unwrap_or("") {
|
||||
"m.room.name" => {
|
||||
if let Some(n) = event["content"]["name"].as_str().filter(|n| !n.is_empty()) {
|
||||
name = Some(n.to_string());
|
||||
}
|
||||
}
|
||||
"m.room.canonical_alias" => {
|
||||
if let Some(a) = event["content"]["alias"].as_str().filter(|a| !a.is_empty()) {
|
||||
alias = Some(a.to_string());
|
||||
}
|
||||
}
|
||||
"m.room.avatar" => {
|
||||
if let Some(u) = event["content"]["url"].as_str().filter(|u| !u.is_empty()) {
|
||||
avatar = Some(u.to_string());
|
||||
}
|
||||
}
|
||||
// Ours is the one that says we were invited; its sender is who
|
||||
// did the inviting. Other members' events ride along in the same
|
||||
// list, so the state_key has to be checked.
|
||||
"m.room.member" => {
|
||||
if event["state_key"].as_str() == Some(own_user_id)
|
||||
&& event["content"]["membership"].as_str() == Some("invite")
|
||||
{
|
||||
inviter = event["sender"].as_str().map(str::to_string);
|
||||
is_direct = event["content"]["is_direct"].as_bool().unwrap_or(false);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let display = name.or(alias).unwrap_or_else(|| room_id.to_string());
|
||||
serde_json::json!({
|
||||
"roomId": room_id,
|
||||
"name": display,
|
||||
"inviter": inviter,
|
||||
"avatarUrl": avatar,
|
||||
"isDirect": is_direct,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod space_tests {
|
||||
use super::{is_space, room_avatar_mxc, space_children};
|
||||
use super::{invite_summary, is_space, room_avatar_mxc, space_children};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn refs(events: &[Value]) -> Vec<&Value> {
|
||||
events.iter().collect()
|
||||
}
|
||||
|
||||
/// The inviter is the part that makes an unnamed room answerable, and it
|
||||
/// comes off our *own* membership event - other members' events ride
|
||||
/// along in the same stripped-state list.
|
||||
#[test]
|
||||
fn an_invite_names_the_room_and_who_sent_it() {
|
||||
let events = vec![
|
||||
json!({ "type": "m.room.name", "content": { "name": "Book club" } }),
|
||||
json!({ "type": "m.room.member", "state_key": "@someone:else.org", "sender": "@someone:else.org", "content": { "membership": "join" } }),
|
||||
json!({ "type": "m.room.member", "state_key": "@me:poa.st", "sender": "@salastil:poa.st", "content": { "membership": "invite" } }),
|
||||
];
|
||||
let got = invite_summary("!abc:poa.st", "@me:poa.st", &refs(&events));
|
||||
assert_eq!(got["roomId"], "!abc:poa.st");
|
||||
assert_eq!(got["name"], "Book club");
|
||||
assert_eq!(got["inviter"], "@salastil:poa.st");
|
||||
assert_eq!(got["isDirect"], false);
|
||||
}
|
||||
|
||||
/// A room with no name falls back to its alias, then to the id - which
|
||||
/// tells nobody anything on its own, which is exactly why the inviter is
|
||||
/// carried alongside it.
|
||||
#[test]
|
||||
fn an_unnamed_invite_falls_back_through_alias_to_the_id() {
|
||||
let aliased = vec![json!({ "type": "m.room.canonical_alias", "content": { "alias": "#books:poa.st" } })];
|
||||
assert_eq!(invite_summary("!abc:poa.st", "@me:poa.st", &refs(&aliased))["name"], "#books:poa.st");
|
||||
|
||||
let bare: Vec<Value> = vec![];
|
||||
assert_eq!(invite_summary("!abc:poa.st", "@me:poa.st", &refs(&bare))["name"], "!abc:poa.st");
|
||||
}
|
||||
|
||||
/// A direct-message invite says so, so it can be shown as a person
|
||||
/// rather than as a room.
|
||||
#[test]
|
||||
fn a_direct_invite_is_marked_as_one() {
|
||||
let events = vec![json!({
|
||||
"type": "m.room.member", "state_key": "@me:poa.st", "sender": "@friend:poa.st",
|
||||
"content": { "membership": "invite", "is_direct": true }
|
||||
})];
|
||||
let got = invite_summary("!dm:poa.st", "@me:poa.st", &refs(&events));
|
||||
assert_eq!(got["isDirect"], true);
|
||||
assert_eq!(got["inviter"], "@friend:poa.st");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_space_is_told_apart_by_its_creation_type() {
|
||||
let space = vec![json!({ "type": "m.room.create", "content": { "type": "m.space" } })];
|
||||
|
||||
@@ -1493,6 +1493,35 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Invitations. Listed rather than turned into buffers: an invite is
|
||||
// not a room you are in, and answering it is a decision somebody has
|
||||
// to make. The set also arrives unprompted as a "matrixInvites"
|
||||
// event on every change, so this is for a client that has just
|
||||
// opened and missed the last one.
|
||||
"listMatrixInvites" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("listMatrixInvites requires \"accountId\"".to_string())),
|
||||
Some(account_id) => (Some(serde_json::Value::Array(state.runtime.matrix_invites(account_id))), None),
|
||||
},
|
||||
|
||||
// Accepting is an ordinary join, and declining is an ordinary leave;
|
||||
// the server drops the invitation either way and stops listing it,
|
||||
// which is how it disappears from the pending set on the next sync.
|
||||
"acceptMatrixInvite" | "declineMatrixInvite" => {
|
||||
let (account_id, room_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "roomId")) {
|
||||
(Some(a), Some(r)) => (a, r),
|
||||
_ => return (None, Some(format!("{method} requires \"accountId\" and \"roomId\""))),
|
||||
};
|
||||
let result = if method == "acceptMatrixInvite" {
|
||||
backend::matrix::join_room(state, account_id, room_id).await
|
||||
} else {
|
||||
backend::matrix::leave_room(state, account_id, room_id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
"joinMatrixRoom" => {
|
||||
let (account_id, room) = match (p_str_opt(params, "accountId"), p_str_opt(params, "roomIdOrAlias")) {
|
||||
(Some(a), Some(r)) => (a, r),
|
||||
|
||||
@@ -132,6 +132,11 @@ pub struct Runtime {
|
||||
/// the same way a dead link does, and without this the account would
|
||||
/// come straight back up seconds after being turned off.
|
||||
wants_connected: Mutex<std::collections::HashSet<String>>,
|
||||
/// Rooms this account has been invited to and not yet answered, by
|
||||
/// account id. Held rather than turned straight into buffers: an invite
|
||||
/// is not a room you are in, has none of its history, and answering it
|
||||
/// is a decision somebody has to make.
|
||||
matrix_invites: Mutex<HashMap<String, Vec<serde_json::Value>>>,
|
||||
/// Last-known member list per buffer (the same JSON shape presenceChange
|
||||
/// events carry) - presenceChange itself is only ever *pushed* on a
|
||||
/// join/part/namreply, so a client subscribing afterward (reopening a
|
||||
@@ -335,6 +340,7 @@ impl Runtime {
|
||||
last_connect_attempt: Mutex::new(HashMap::new()),
|
||||
connect_generation: Mutex::new(HashMap::new()),
|
||||
wants_connected: Mutex::new(std::collections::HashSet::new()),
|
||||
matrix_invites: Mutex::new(HashMap::new()),
|
||||
buffers: Mutex::new(HashMap::new()),
|
||||
presence: Mutex::new(HashMap::new()),
|
||||
own_identity: Mutex::new(HashMap::new()),
|
||||
@@ -697,6 +703,29 @@ impl Runtime {
|
||||
self.wants_connected.lock().unwrap().contains(account_id)
|
||||
}
|
||||
|
||||
/// Replaces this account's pending invitations, and says whether that
|
||||
/// changed anything.
|
||||
///
|
||||
/// Every sync carries the full invite set, so this is a replace rather
|
||||
/// than a merge - an invite answered from another client simply stops
|
||||
/// being listed, and that is how it disappears here too. The changed
|
||||
/// answer is what keeps an event from being emitted on every sync tick
|
||||
/// for a set nobody has touched.
|
||||
pub fn set_matrix_invites(&self, account_id: &str, invites: Vec<serde_json::Value>) -> bool {
|
||||
let mut all = self.matrix_invites.lock().unwrap();
|
||||
match all.get(account_id) {
|
||||
Some(existing) if *existing == invites => false,
|
||||
_ => {
|
||||
all.insert(account_id.to_string(), invites);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matrix_invites(&self, account_id: &str) -> Vec<serde_json::Value> {
|
||||
self.matrix_invites.lock().unwrap().get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Forcibly tears down any previous connection attempt/session for this
|
||||
/// account before a new one starts - called at the very top of
|
||||
/// backend::irc::spawn(), before next_generation(). Without this, an old
|
||||
|
||||
Reference in New Issue
Block a user