Read and write the list that says what a DM is
A direct message was recognised only by is_direct on an invitation, and m.direct - where Matrix actually keeps the list - was never read and never written. So a conversation started in Element was an ordinary room here, and one started here was an ordinary room everywhere else: outside the DM group in the rail, named after the room rather than the person (#214). Both directions now. The list is read at connect and followed as it changes, and derive_room_info takes the account's own answer, which outranks the member count in both directions - a DM can have three people in it, and a two-person room need not be one. The count stays as the fallback for an account whose list has never been written by any client. Written on the two occasions a DM comes into being: opening one from here, and accepting an invitation marked is_direct - the invitee has to write that one, since the inviter cannot touch somebody else's account data. Both read-modify-write against the server's copy, because account data is replaced whole and another client may have changed it since; writing only what this client knows would un-DM every other conversation on every device. Name and kind are decided separately now. A named DM keeps its name and still files with the conversations, which is what Element does - and an unnamed room with several people in it keeps its id rather than taking the name of whichever member the state mentioned last. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
//! Which rooms this account calls direct messages.
|
||||
//!
|
||||
//! Matrix keeps that list in one place - the `m.direct` account data event,
|
||||
//! a map of user id to the rooms shared with them - and it is the only
|
||||
//! answer that travels. A room is a DM because the account says so, not
|
||||
//! because of anything visible in the room itself: a DM can have three
|
||||
//! people in it, a name, and a topic, and an ordinary two-person room is not
|
||||
//! a DM at all.
|
||||
//!
|
||||
//! Before this, a DM was guessed at: two joined members and no name, or an
|
||||
//! `is_direct` flag read off an invitation. That guess disagrees with every
|
||||
//! other client in both directions - a conversation started in Element
|
||||
//! arrived here as an ordinary room named after its id, and one started here
|
||||
//! was an ordinary room everywhere else, because nothing wrote the list
|
||||
//! back. The guess is kept as a fallback for an account whose list has not
|
||||
//! been read yet; where the list says anything, the list wins.
|
||||
|
||||
use super::*;
|
||||
use serde_json::{json, Map};
|
||||
|
||||
/// The list, flattened to the way a room is asked about: room id -> the
|
||||
/// person it is with.
|
||||
///
|
||||
/// Inverted on the way in because every question here is "is *this room* a
|
||||
/// DM, and who with" - the account data is keyed the other way, by person,
|
||||
/// since one person can have several rooms with you.
|
||||
pub fn read(content: &Value) -> Vec<(String, String)> {
|
||||
let Some(by_user) = content.as_object() else { return Vec::new() };
|
||||
let mut pairs = Vec::new();
|
||||
for (user_id, rooms) in by_user {
|
||||
for room in rooms.as_array().into_iter().flatten() {
|
||||
if let Some(room_id) = room.as_str().filter(|r| !r.is_empty()) {
|
||||
pairs.push((room_id.to_string(), user_id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// The list with one more room in it.
|
||||
///
|
||||
/// Read-modify-write, because account data is replaced whole and this client
|
||||
/// is not the only thing writing it: dropping the other entries would un-DM
|
||||
/// every other conversation the account has, on every device. Adding a room
|
||||
/// already listed changes nothing rather than listing it twice.
|
||||
pub fn with(content: &Value, user_id: &str, room_id: &str) -> Value {
|
||||
let mut by_user = content.as_object().cloned().unwrap_or_else(Map::new);
|
||||
let rooms = by_user.entry(user_id.to_string()).or_insert_with(|| json!([]));
|
||||
let already = rooms.as_array().into_iter().flatten().any(|r| r.as_str() == Some(room_id));
|
||||
if !already {
|
||||
if let Some(list) = rooms.as_array_mut() {
|
||||
list.push(json!(room_id));
|
||||
} else {
|
||||
*rooms = json!([room_id]);
|
||||
}
|
||||
}
|
||||
Value::Object(by_user)
|
||||
}
|
||||
|
||||
/// The list without a room, wherever it appears.
|
||||
///
|
||||
/// A person is dropped entirely when their last room goes: an empty array
|
||||
/// left behind is a person the account claims to have a DM list for and no
|
||||
/// DMs with, which other clients render as an empty conversation.
|
||||
pub fn without(content: &Value, room_id: &str) -> Value {
|
||||
let Some(by_user) = content.as_object() else { return json!({}) };
|
||||
let mut out = Map::new();
|
||||
for (user_id, rooms) in by_user {
|
||||
let kept: Vec<Value> = rooms
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|r| r.as_str() != Some(room_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !kept.is_empty() {
|
||||
out.insert(user_id.clone(), Value::Array(kept));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// Reads the account's list once, at connect.
|
||||
///
|
||||
/// Asked for directly rather than waited for: a resumed connection gets only
|
||||
/// incremental syncs, and account data that has not changed since the last
|
||||
/// cursor never arrives again - so an account that had not reopened a DM
|
||||
/// since before this existed would sit there with every conversation filed as
|
||||
/// an ordinary room.
|
||||
pub(super) async fn fetch(state: &AppState, account_id: &str, homeserver_url: &str, access_token: &str, user_id: &str) {
|
||||
let base = homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/user/{}/account_data/m.direct",
|
||||
url::form_urlencoded::byte_serialize(user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
match http::get_json(&url, access_token).await {
|
||||
Ok(content) => apply(state, account_id, &content),
|
||||
// A 404 is an account that has never had a DM, which is ordinary.
|
||||
Err(e) => tracing::debug!("matrix[{account_id}]: reading the direct list: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes an `m.direct` content this account's list, and moves any room
|
||||
/// already on screen that it disagrees with.
|
||||
///
|
||||
/// The second half is the part that matters when the list changes rather than
|
||||
/// when it is first read: a room's name and kind are derived once and cached
|
||||
/// (see `rooms`), so a conversation that was already a buffer when Element
|
||||
/// made it a DM would otherwise stay an ordinary room until a restart.
|
||||
pub(super) fn apply(state: &AppState, account_id: &str, content: &Value) {
|
||||
let pairs = read(content);
|
||||
state.runtime.set_matrix_directs(account_id, pairs.clone());
|
||||
for (room_id, _) in pairs {
|
||||
let Some((name, kind)) = state.runtime.get_matrix_room_name(account_id, &room_id) else { continue };
|
||||
if kind == "dm" {
|
||||
continue;
|
||||
}
|
||||
state.runtime.set_matrix_room_name(account_id, &room_id, &name, "dm");
|
||||
if let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, &room_id) {
|
||||
state.runtime.set_buffer_kind(state, &buffer_id, "dm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the account - and therefore every other client it signs in from -
|
||||
/// that this room is a direct message with this person.
|
||||
///
|
||||
/// Read-modify-write against the server's own copy rather than against what
|
||||
/// is held here, because the list is replaced whole and another client may
|
||||
/// have written it since. Best-effort: failing to record a DM is worth a
|
||||
/// line in the log and not worth failing the conversation over.
|
||||
pub(super) async fn record(state: &AppState, account_id: &str, peer_user_id: &str, room_id: &str) {
|
||||
let Some(account) = state.accounts.get_matrix(account_id) else { return };
|
||||
let existing = ssss::read_account_data(&account.homeserver_url, &account.access_token, &account.user_id, "m.direct")
|
||||
.await
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let updated = with(&existing, peer_user_id, room_id);
|
||||
match ssss::write_account_data(&account.homeserver_url, &account.access_token, &account.user_id, "m.direct", updated.clone()).await {
|
||||
Ok(()) => apply(state, account_id, &updated),
|
||||
Err(e) => tracing::warn!("matrix[{account_id}]: recording {room_id} as a direct message: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_list_is_read_the_way_a_room_is_asked_about() {
|
||||
let content = json!({
|
||||
"@anna:poa.st": ["!one:poa.st", "!two:poa.st"],
|
||||
"@bob:poa.st": ["!three:poa.st"],
|
||||
// A person with no rooms is not a DM with anybody.
|
||||
"@carol:poa.st": [],
|
||||
});
|
||||
let mut pairs = read(&content);
|
||||
pairs.sort();
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![
|
||||
("!one:poa.st".to_string(), "@anna:poa.st".to_string()),
|
||||
("!three:poa.st".to_string(), "@bob:poa.st".to_string()),
|
||||
("!two:poa.st".to_string(), "@anna:poa.st".to_string()),
|
||||
]
|
||||
);
|
||||
// An account that has never had one, and a server answering with
|
||||
// something else entirely, both mean "no DMs" rather than an error.
|
||||
assert!(read(&json!({})).is_empty());
|
||||
assert!(read(&json!("nonsense")).is_empty());
|
||||
}
|
||||
|
||||
/// The list is replaced whole, so writing back only what this client
|
||||
/// knows would un-DM every other conversation on every other device.
|
||||
#[test]
|
||||
fn adding_a_room_keeps_everybody_elses() {
|
||||
let before = json!({ "@anna:poa.st": ["!one:poa.st"], "@bob:poa.st": ["!three:poa.st"] });
|
||||
let after = with(&before, "@anna:poa.st", "!new:poa.st");
|
||||
assert_eq!(after["@anna:poa.st"], json!(["!one:poa.st", "!new:poa.st"]));
|
||||
assert_eq!(after["@bob:poa.st"], json!(["!three:poa.st"]));
|
||||
|
||||
// Somebody with no entry yet gets one.
|
||||
let fresh = with(&json!({}), "@carol:poa.st", "!c:poa.st");
|
||||
assert_eq!(fresh["@carol:poa.st"], json!(["!c:poa.st"]));
|
||||
|
||||
// And a room already listed is not listed twice - which is what
|
||||
// happens every time "Open DM" is clicked on an existing
|
||||
// conversation.
|
||||
let again = with(&after, "@anna:poa.st", "!new:poa.st");
|
||||
assert_eq!(again["@anna:poa.st"], json!(["!one:poa.st", "!new:poa.st"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_room_takes_the_person_with_it_when_it_was_the_last_one() {
|
||||
let before = json!({ "@anna:poa.st": ["!one:poa.st", "!two:poa.st"], "@bob:poa.st": ["!three:poa.st"] });
|
||||
let after = without(&before, "!three:poa.st");
|
||||
assert_eq!(after["@anna:poa.st"], json!(["!one:poa.st", "!two:poa.st"]));
|
||||
// Not an empty list: a person with no rooms reads to other clients
|
||||
// as a conversation with nothing in it.
|
||||
assert!(after.get("@bob:poa.st").is_none());
|
||||
|
||||
let one_left = without(&before, "!one:poa.st");
|
||||
assert_eq!(one_left["@anna:poa.st"], json!(["!two:poa.st"]));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod directory;
|
||||
pub mod directs;
|
||||
pub mod history;
|
||||
pub mod media;
|
||||
pub mod notifications;
|
||||
|
||||
+142
-17
@@ -16,17 +16,25 @@ pub struct RoomInfo {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Priority: `m.room.name` -> `m.room.canonical_alias` -> (exactly 2
|
||||
/// joined members: the other member's display name, kind "dm") -> the raw
|
||||
/// room id, kind "channel". `events` should be the union of this sync
|
||||
/// response's `state.events` and any state-key-bearing `timeline.events`
|
||||
/// for the room (Matrix inlines recent state changes into the timeline
|
||||
/// rather than always the separate state array) - see mod.rs's call site.
|
||||
pub fn derive_room_info(room_id: &str, own_user_id: &str, events: &[&Value]) -> RoomInfo {
|
||||
/// Priority: `m.room.name` -> `m.room.canonical_alias` -> (the person the
|
||||
/// account's `m.direct` list says this room is with, or failing that exactly
|
||||
/// 2 joined members: their display name, kind "dm") -> the raw room id, kind
|
||||
/// "channel". `events` should be the union of this sync response's
|
||||
/// `state.events` and any state-key-bearing `timeline.events` for the room
|
||||
/// (Matrix inlines recent state changes into the timeline rather than always
|
||||
/// the separate state array) - see mod.rs's call site.
|
||||
///
|
||||
/// `direct_peer` is the account's own answer, from `m.direct` (see the
|
||||
/// `directs` module), and it outranks the member count entirely: a DM can
|
||||
/// have three people in it and a two-person room need not be one. The count
|
||||
/// stays as the fallback for an account whose list has not been read yet, or
|
||||
/// one that has never had a DM written to it by any client.
|
||||
pub fn derive_room_info(room_id: &str, own_user_id: &str, events: &[&Value], direct_peer: Option<&str>) -> RoomInfo {
|
||||
let mut room_name: Option<String> = None;
|
||||
let mut canonical_alias: Option<String> = None;
|
||||
let mut joined_count = 0usize;
|
||||
let mut other_member_name: Option<String> = None;
|
||||
let mut peer_name: Option<String> = None;
|
||||
|
||||
for event in events {
|
||||
match event["type"].as_str().unwrap_or("") {
|
||||
@@ -43,6 +51,16 @@ pub fn derive_room_info(room_id: &str, own_user_id: &str, events: &[&Value]) ->
|
||||
}
|
||||
}
|
||||
"m.room.member" => {
|
||||
// What to call the person this room is *with*, at any
|
||||
// membership: a DM opened from here is named after somebody
|
||||
// who has only been invited, and waiting for them to join
|
||||
// would leave the conversation labelled with a room id until
|
||||
// they did.
|
||||
if direct_peer.is_some() && event["state_key"].as_str() == direct_peer {
|
||||
if let Some(display) = event["content"]["displayname"].as_str().filter(|s| !s.is_empty()) {
|
||||
peer_name = Some(display.to_string());
|
||||
}
|
||||
}
|
||||
if event["content"]["membership"].as_str() == Some("join") {
|
||||
joined_count += 1;
|
||||
let sender = event["sender"].as_str().unwrap_or("");
|
||||
@@ -56,15 +74,34 @@ pub fn derive_room_info(room_id: &str, own_user_id: &str, events: &[&Value]) ->
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = room_name.or(canonical_alias) {
|
||||
return RoomInfo { name, kind: "channel".to_string() };
|
||||
}
|
||||
if joined_count == 2 {
|
||||
if let Some(name) = other_member_name {
|
||||
return RoomInfo { name, kind: "dm".to_string() };
|
||||
}
|
||||
}
|
||||
RoomInfo { name: room_id.to_string(), kind: "channel".to_string() }
|
||||
// A DM is a DM whatever it is called. Element shows a named DM under its
|
||||
// name and still files it with the conversations, and the kind is what
|
||||
// decides where it goes in the rail - so the name and the kind are
|
||||
// decided separately rather than one falling out of the other.
|
||||
let kind = if direct_peer.is_some() || (room_name.is_none() && canonical_alias.is_none() && joined_count == 2 && other_member_name.is_some()) {
|
||||
"dm"
|
||||
} else {
|
||||
"channel"
|
||||
};
|
||||
|
||||
let name = room_name
|
||||
.or(canonical_alias)
|
||||
.or_else(|| {
|
||||
// Only a DM is named after a person. An unnamed room with several
|
||||
// people in it is not named after whichever of them the state
|
||||
// happened to mention last.
|
||||
if kind != "dm" {
|
||||
return None;
|
||||
}
|
||||
peer_name
|
||||
// The mxid itself is a worse name than a display name and a
|
||||
// far better one than a room id.
|
||||
.or_else(|| direct_peer.map(str::to_string))
|
||||
.or(other_member_name)
|
||||
})
|
||||
.unwrap_or_else(|| room_id.to_string());
|
||||
|
||||
RoomInfo { name, kind: kind.to_string() }
|
||||
}
|
||||
|
||||
pub fn is_encrypted(events: &[&Value]) -> bool {
|
||||
@@ -180,7 +217,7 @@ pub fn invite_summary(room_id: &str, own_user_id: &str, events: &[&Value]) -> se
|
||||
|
||||
#[cfg(test)]
|
||||
mod space_tests {
|
||||
use super::{invite_summary, is_space, room_avatar_mxc, space_children};
|
||||
use super::{derive_room_info, invite_summary, is_space, room_avatar_mxc, space_children};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn refs(events: &[Value]) -> Vec<&Value> {
|
||||
@@ -229,6 +266,94 @@ mod space_tests {
|
||||
assert_eq!(got["inviter"], "@friend:poa.st");
|
||||
}
|
||||
|
||||
fn member(mxid: &str, name: &str, membership: &str) -> Value {
|
||||
json!({ "type": "m.room.member", "state_key": mxid, "sender": mxid,
|
||||
"content": { "membership": membership, "displayname": name } })
|
||||
}
|
||||
|
||||
/// The account's own m.direct list decides, and it outranks the member
|
||||
/// count in both directions: a DM can have three people in it, and an
|
||||
/// ordinary two-person room is not a conversation just because it is
|
||||
/// small.
|
||||
#[test]
|
||||
fn the_account_says_which_rooms_are_direct_messages() {
|
||||
let two = vec![member("@me:poa.st", "me", "join"), member("@anna:poa.st", "Anna", "join")];
|
||||
|
||||
// Listed: a DM, named after the person, whatever else is in it.
|
||||
let listed = derive_room_info("!dm:poa.st", "@me:poa.st", &refs(&two), Some("@anna:poa.st"));
|
||||
assert_eq!(listed.kind, "dm");
|
||||
assert_eq!(listed.name, "Anna");
|
||||
|
||||
// A third person in a room the account still calls a DM - which is
|
||||
// ordinary, and which the old two-member count got wrong.
|
||||
let three = vec![
|
||||
member("@me:poa.st", "me", "join"),
|
||||
member("@anna:poa.st", "Anna", "join"),
|
||||
member("@bob:poa.st", "Bob", "join"),
|
||||
];
|
||||
let crowded = derive_room_info("!dm:poa.st", "@me:poa.st", &refs(&three), Some("@anna:poa.st"));
|
||||
assert_eq!(crowded.kind, "dm");
|
||||
assert_eq!(crowded.name, "Anna");
|
||||
|
||||
// Not listed, and no name: the old heuristic still answers, because
|
||||
// an account whose list has never been written has no other answer.
|
||||
let guessed = derive_room_info("!x:poa.st", "@me:poa.st", &refs(&two), None);
|
||||
assert_eq!(guessed.kind, "dm");
|
||||
assert_eq!(guessed.name, "Anna");
|
||||
}
|
||||
|
||||
/// A DM with a name is shown under its name and still filed with the
|
||||
/// conversations - the two decisions are separate, which is what Element
|
||||
/// does.
|
||||
#[test]
|
||||
fn a_named_direct_message_keeps_its_name_and_its_kind() {
|
||||
let events = vec![
|
||||
json!({ "type": "m.room.name", "content": { "name": "Holiday plans" } }),
|
||||
member("@me:poa.st", "me", "join"),
|
||||
member("@anna:poa.st", "Anna", "join"),
|
||||
];
|
||||
let got = derive_room_info("!dm:poa.st", "@me:poa.st", &refs(&events), Some("@anna:poa.st"));
|
||||
assert_eq!(got.name, "Holiday plans");
|
||||
assert_eq!(got.kind, "dm");
|
||||
|
||||
// And the same room with nobody claiming it as a DM is an ordinary
|
||||
// room, as it always was.
|
||||
let plain = derive_room_info("!dm:poa.st", "@me:poa.st", &refs(&events), None);
|
||||
assert_eq!(plain.kind, "channel");
|
||||
}
|
||||
|
||||
/// A conversation opened from here is named after somebody who has only
|
||||
/// been invited. Waiting for them to join would leave it labelled with a
|
||||
/// room id until they did - which on an unanswered invitation is for ever.
|
||||
#[test]
|
||||
fn a_direct_message_is_named_before_the_other_person_arrives() {
|
||||
let events = vec![member("@me:poa.st", "me", "join"), member("@anna:poa.st", "Anna", "invite")];
|
||||
let got = derive_room_info("!new:poa.st", "@me:poa.st", &refs(&events), Some("@anna:poa.st"));
|
||||
assert_eq!(got.kind, "dm");
|
||||
assert_eq!(got.name, "Anna");
|
||||
|
||||
// With no display name anywhere, the mxid - a worse name than a
|
||||
// display name and a far better one than a room id.
|
||||
let nameless = vec![json!({ "type": "m.room.member", "state_key": "@anna:poa.st", "content": { "membership": "invite" } })];
|
||||
let got = derive_room_info("!new:poa.st", "@me:poa.st", &refs(&nameless), Some("@anna:poa.st"));
|
||||
assert_eq!(got.name, "@anna:poa.st");
|
||||
}
|
||||
|
||||
/// An unnamed room with several people in it keeps its id rather than
|
||||
/// taking the name of whichever member the state happened to mention
|
||||
/// last - which would be arbitrary and would change between syncs.
|
||||
#[test]
|
||||
fn an_unnamed_group_room_is_not_named_after_somebody() {
|
||||
let three = vec![
|
||||
member("@me:poa.st", "me", "join"),
|
||||
member("@anna:poa.st", "Anna", "join"),
|
||||
member("@bob:poa.st", "Bob", "join"),
|
||||
];
|
||||
let got = derive_room_info("!x:poa.st", "@me:poa.st", &refs(&three), None);
|
||||
assert_eq!(got.kind, "channel");
|
||||
assert_eq!(got.name, "!x: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" } })];
|
||||
|
||||
@@ -46,6 +46,33 @@ pub async fn join_room(state: &AppState, account_id: &str, room_id_or_alias: &st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accepting an invitation, which is a join and one thing more.
|
||||
///
|
||||
/// `is_direct` on the invitation is how the inviter said "this is a
|
||||
/// conversation, not a room". It is a hint carried on one event and nothing
|
||||
/// keeps it: the account's own `m.direct` list is where the fact lives, and
|
||||
/// the invitee is the one who has to write it - the inviter cannot write to
|
||||
/// somebody else's account data. Without this, accepting a DM from Element
|
||||
/// gave an ordinary room here and stayed one.
|
||||
pub async fn accept_invite(state: &AppState, account_id: &str, room_id: &str) -> Result<()> {
|
||||
// Read before the join, because the invitation stops being listed once
|
||||
// it has been accepted.
|
||||
let direct_with = state
|
||||
.runtime
|
||||
.matrix_invites(account_id)
|
||||
.into_iter()
|
||||
.find(|invite| invite["roomId"].as_str() == Some(room_id))
|
||||
.filter(|invite| invite["isDirect"].as_bool() == Some(true))
|
||||
.and_then(|invite| invite["inviter"].as_str().map(str::to_string));
|
||||
|
||||
join_room(state, account_id, room_id, &[]).await?;
|
||||
|
||||
if let Some(peer) = direct_with {
|
||||
directs::record(state, account_id, &peer, room_id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends one of the account's stickers.
|
||||
///
|
||||
/// Its own event type rather than a message with an image in it, which is what
|
||||
@@ -499,6 +526,12 @@ pub async fn open_dm(state: &AppState, account_id: &str, target_user_id: &str, t
|
||||
let buffer = state.runtime.ensure_buffer(state, account_id, &name, "dm");
|
||||
state.runtime.set_matrix_room(state, &buffer.id, &room_id);
|
||||
|
||||
// And told to the account, so the conversation is a conversation in
|
||||
// Element too. `is_direct` on the invitation is a hint to the person
|
||||
// being invited; `m.direct` is where the fact is actually kept, and
|
||||
// without this a DM opened here was an ordinary room everywhere else.
|
||||
directs::record(state, account_id, target_user_id, &room_id).await;
|
||||
|
||||
Ok(buffer.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,12 @@ pub(super) async fn run_sync(state: &AppState, config: &MatrixAccountConfig, acc
|
||||
// "connected" and genuinely being in many rooms. A fresh account
|
||||
// (next_batch still None) doesn't need this - its first /sync already
|
||||
// sends full state for every joined room on its own.
|
||||
// Which rooms this account calls direct messages, before any of them are
|
||||
// named. A room named without it is named once and cached, so reading
|
||||
// this late would leave a conversation filed as an ordinary room for the
|
||||
// rest of the session.
|
||||
directs::fetch(state, account_id, &config.homeserver_url, &access_token, &config.user_id).await;
|
||||
|
||||
if next_batch.is_some() {
|
||||
if let Err(e) = bootstrap_joined_rooms(state, account_id, &config.user_id, &config.homeserver_url, &access_token).await {
|
||||
tracing::warn!("matrix[{account_id}]: bootstrapping joined rooms failed: {e:#}");
|
||||
@@ -417,7 +423,7 @@ pub(super) async fn register_room(
|
||||
return;
|
||||
}
|
||||
|
||||
let info = rooms::derive_room_info(room_id, own_user_id, &event_refs);
|
||||
let info = rooms::derive_room_info(room_id, own_user_id, &event_refs, state.runtime.matrix_direct_peer(account_id, room_id).as_deref());
|
||||
state.runtime.set_matrix_room_name(account_id, room_id, &info.name, &info.kind);
|
||||
|
||||
let buffer = state.runtime.ensure_buffer(state, account_id, &info.name, &info.kind);
|
||||
@@ -495,6 +501,12 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
|
||||
if event["type"].as_str() == Some("m.ignored_user_list") {
|
||||
apply_ignored_users(state, account_id, &event["content"]);
|
||||
}
|
||||
// Which rooms this account calls direct messages. Written from
|
||||
// any client, so a conversation started in Element arrives here
|
||||
// as this rather than as anything about the room itself.
|
||||
if event["type"].as_str() == Some("m.direct") {
|
||||
directs::apply(state, account_id, &event["content"]);
|
||||
}
|
||||
// The account's own sticker pack, which travels with it between
|
||||
// clients - see backend/matrix/stickers.rs for the two places a
|
||||
// pack lives.
|
||||
@@ -700,7 +712,12 @@ pub(super) async fn process_sync_response(state: &AppState, account_id: &str, ow
|
||||
continue;
|
||||
}
|
||||
|
||||
let info = rooms::derive_room_info(room_id, own_user_id, &naming_events);
|
||||
let info = rooms::derive_room_info(
|
||||
room_id,
|
||||
own_user_id,
|
||||
&naming_events,
|
||||
state.runtime.matrix_direct_peer(account_id, room_id).as_deref(),
|
||||
);
|
||||
state.runtime.set_matrix_room_name(account_id, room_id, &info.name, &info.kind);
|
||||
// Same full-state snapshot naming just derived from - also
|
||||
// seeds member avatars/room avatar/power levels the first
|
||||
@@ -866,7 +883,8 @@ pub(super) async fn register_space(
|
||||
access_token: &str,
|
||||
events: &[&Value],
|
||||
) {
|
||||
let name = rooms::derive_room_info(room_id, "", events).name;
|
||||
// A space is never a direct message, so it is asked with none.
|
||||
let name = rooms::derive_room_info(room_id, "", events, None).name;
|
||||
let group_id = space_group_id(account_id, room_id);
|
||||
|
||||
// Resolved to a local file for the same reason room avatars are: the mxc
|
||||
|
||||
+1
-1
@@ -4048,7 +4048,7 @@ pub async fn dispatch(
|
||||
_ => return (None, Some(format!("{method} requires \"accountId\" and \"roomId\""))),
|
||||
};
|
||||
let result = if method == "acceptMatrixInvite" {
|
||||
backend::matrix::join_room(state, account_id, room_id, &[]).await
|
||||
backend::matrix::accept_invite(state, account_id, room_id).await
|
||||
} else {
|
||||
backend::matrix::leave_room(state, account_id, room_id).await
|
||||
};
|
||||
|
||||
@@ -697,6 +697,15 @@ pub struct Runtime {
|
||||
/// 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>>>,
|
||||
/// (account, room) -> the person a room is a direct message with, per the
|
||||
/// account's own `m.direct` list.
|
||||
///
|
||||
/// The list is the only answer that travels between clients, and it is
|
||||
/// what decides whether a room is a DM - not anything visible in the room
|
||||
/// itself. Held inverted (by room) because every question asked of it is
|
||||
/// "is this room a DM"; the account data is keyed by person, since one
|
||||
/// person can have several rooms with you.
|
||||
matrix_directs: Mutex<HashMap<(String, String), String>>,
|
||||
/// 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,
|
||||
@@ -1025,6 +1034,7 @@ impl Runtime {
|
||||
matrix_power_levels: Mutex::new(HashMap::new()),
|
||||
matrix_pinned: Mutex::new(HashMap::new()),
|
||||
matrix_widgets: Mutex::new(HashMap::new()),
|
||||
matrix_directs: Mutex::new(HashMap::new()),
|
||||
matrix_verification_peers: Mutex::new(HashMap::new()),
|
||||
matrix_ignored: Mutex::new(HashMap::new()),
|
||||
matrix_room_members: Mutex::new(HashMap::new()),
|
||||
@@ -1356,6 +1366,26 @@ impl Runtime {
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Changes what a buffer already on screen is.
|
||||
///
|
||||
/// Rare on purpose: a buffer's kind is decided when it is created and is
|
||||
/// not something that drifts. The one case that needs it is a room the
|
||||
/// account newly calls a direct message - `m.direct` can be written from
|
||||
/// another client at any time, and a conversation that was an ordinary
|
||||
/// room a moment ago belongs with the conversations from now on.
|
||||
pub fn set_buffer_kind(&self, state: &AppState, buffer_id: &str, kind: &str) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
|
||||
if buffer.kind == kind {
|
||||
return;
|
||||
}
|
||||
buffer.kind = kind.to_string();
|
||||
buffer.clone()
|
||||
};
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
|
||||
}
|
||||
|
||||
pub fn set_conn_state(&self, state: &AppState, account_id: &str, conn: ConnState, error: Option<&str>) {
|
||||
self.conn_states.lock().unwrap().insert(account_id.to_string(), conn.clone());
|
||||
let mut data = json!({ "accountId": account_id, "state": conn.as_str() });
|
||||
@@ -2678,6 +2708,35 @@ impl Runtime {
|
||||
/// 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.
|
||||
/// Replaces this account's whole `m.direct` list.
|
||||
///
|
||||
/// Whole rather than merged, because that is how the event itself
|
||||
/// arrives: a room removed from the list in another client is removed by
|
||||
/// its absence, and merging would make un-DMing a room impossible here.
|
||||
pub fn set_matrix_directs(&self, account_id: &str, pairs: Vec<(String, String)>) {
|
||||
let mut held = self.matrix_directs.lock().unwrap();
|
||||
held.retain(|(account, _), _| account != account_id);
|
||||
for (room_id, user_id) in pairs {
|
||||
held.insert((account_id.to_string(), room_id), user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who a room is a direct message with, if the account says it is one.
|
||||
pub fn matrix_direct_peer(&self, account_id: &str, room_id: &str) -> Option<String> {
|
||||
self.matrix_directs.lock().unwrap().get(&(account_id.to_string(), room_id.to_string())).cloned()
|
||||
}
|
||||
|
||||
/// Every room this account calls a direct message.
|
||||
pub fn matrix_direct_rooms(&self, account_id: &str) -> Vec<String> {
|
||||
self.matrix_directs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|(account, _)| account == account_id)
|
||||
.map(|(_, room)| room.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn matrix_widgets(&self, account_id: &str, room_id: &str) -> Vec<serde_json::Value> {
|
||||
self.matrix_widgets
|
||||
.lock()
|
||||
@@ -4585,6 +4644,7 @@ impl Runtime {
|
||||
matrix_room_members, matrix_read_receipts, sneedchat_motds,
|
||||
irc_whois, discord_mutes, matrix_presence,
|
||||
matrix_own_reactions, irc_splits, matrix_widgets,
|
||||
matrix_directs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user