You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2160 lines
74 KiB
2160 lines
74 KiB
use std::{cell::RefCell, collections::HashSet}; |
|
|
|
use futures_util::StreamExt; |
|
use gettextrs::gettext; |
|
use gtk::{ |
|
glib, |
|
glib::{clone, closure_local}, |
|
prelude::*, |
|
subclass::prelude::*, |
|
}; |
|
use matrix_sdk::{ |
|
deserialized_responses::AmbiguityChange, event_handler::EventHandlerDropGuard, |
|
room::Room as MatrixRoom, send_queue::RoomSendQueueUpdate, DisplayName, Result as MatrixResult, |
|
RoomInfo, RoomMemberships, RoomState, |
|
}; |
|
use ruma::{ |
|
api::client::{ |
|
error::{ErrorKind, RetryAfter}, |
|
receipt::create_receipt::v3::ReceiptType as ApiReceiptType, |
|
}, |
|
events::{ |
|
receipt::ReceiptThread, |
|
room::{ |
|
guest_access::GuestAccess, history_visibility::HistoryVisibility, |
|
member::SyncRoomMemberEvent, |
|
}, |
|
}, |
|
EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, |
|
}; |
|
use tokio_stream::wrappers::BroadcastStream; |
|
use tracing::{debug, error, warn}; |
|
|
|
mod aliases; |
|
mod category; |
|
mod event; |
|
mod highlight_flags; |
|
mod join_rule; |
|
mod member; |
|
mod member_list; |
|
mod permissions; |
|
mod timeline; |
|
mod typing_list; |
|
|
|
pub use self::{ |
|
aliases::{AddAltAliasError, RegisterLocalAliasError, RoomAliases}, |
|
category::RoomCategory, |
|
event::*, |
|
highlight_flags::HighlightFlags, |
|
join_rule::{JoinRule, JoinRuleValue}, |
|
member::{Member, Membership}, |
|
member_list::MemberList, |
|
permissions::*, |
|
timeline::*, |
|
typing_list::TypingList, |
|
}; |
|
use super::{ |
|
notifications::NotificationsRoomSetting, room_list::RoomMetainfo, IdentityVerification, |
|
Session, User, |
|
}; |
|
use crate::{ |
|
components::{AtRoom, AvatarImage, AvatarUriSource, PillSource}, |
|
gettext_f, |
|
prelude::*, |
|
spawn, spawn_tokio, |
|
utils::{string::linkify, BoundObjectWeakRef}, |
|
}; |
|
|
|
/// The default duration in seconds that we wait for before retrying failed |
|
/// sending requests. |
|
const DEFAULT_RETRY_AFTER: u32 = 30; |
|
|
|
mod imp { |
|
use std::{ |
|
cell::{Cell, OnceCell}, |
|
marker::PhantomData, |
|
ops::ControlFlow, |
|
time::SystemTime, |
|
}; |
|
|
|
use glib::subclass::Signal; |
|
use once_cell::sync::Lazy; |
|
|
|
use super::*; |
|
|
|
#[derive(Default, glib::Properties)] |
|
#[properties(wrapper_type = super::Room)] |
|
pub struct Room { |
|
/// The room API of the SDK. |
|
matrix_room: OnceCell<MatrixRoom>, |
|
/// The current session. |
|
#[property(get, set = Self::set_session, construct_only)] |
|
session: glib::WeakRef<Session>, |
|
/// The ID of this room, as a string. |
|
#[property(get = Self::room_id_string)] |
|
room_id_string: PhantomData<String>, |
|
/// The aliases of this room. |
|
#[property(get)] |
|
aliases: RoomAliases, |
|
/// The name that is set for this room. |
|
/// |
|
/// This can be empty, the display name should be used instead in the |
|
/// interface. |
|
#[property(get)] |
|
name: RefCell<Option<String>>, |
|
/// Whether this room has an avatar explicitly set. |
|
/// |
|
/// This is `false` if there is no avatar or if the avatar is the one |
|
/// from the other member. |
|
#[property(get)] |
|
has_avatar: Cell<bool>, |
|
/// The topic of this room. |
|
#[property(get)] |
|
topic: RefCell<Option<String>>, |
|
/// The linkified topic of this room. |
|
/// |
|
/// This is the string that should be used in the interface when markup |
|
/// is allowed. |
|
#[property(get)] |
|
topic_linkified: RefCell<Option<String>>, |
|
/// The category of this room. |
|
#[property(get, builder(RoomCategory::default()))] |
|
category: Cell<RoomCategory>, |
|
/// Whether this room is a direct chat. |
|
#[property(get)] |
|
is_direct: Cell<bool>, |
|
/// Whether this room has been upgraded. |
|
#[property(get)] |
|
is_tombstoned: Cell<bool>, |
|
/// The ID of the room that was upgraded and that this one replaces. |
|
pub(super) predecessor_id: OnceCell<OwnedRoomId>, |
|
/// The ID of the room that was upgraded and that this one replaces, as |
|
/// a string. |
|
#[property(get = Self::predecessor_id_string)] |
|
predecessor_id_string: PhantomData<Option<String>>, |
|
/// The ID of the successor of this Room, if this room was upgraded. |
|
pub(super) successor_id: OnceCell<OwnedRoomId>, |
|
/// The ID of the successor of this Room, if this room was upgraded, as |
|
/// a string. |
|
#[property(get = Self::successor_id_string)] |
|
successor_id_string: PhantomData<Option<String>>, |
|
/// The successor of this Room, if this room was upgraded and the |
|
/// successor was joined. |
|
#[property(get)] |
|
successor: glib::WeakRef<super::Room>, |
|
/// The members of this room. |
|
#[property(get)] |
|
pub(super) members: glib::WeakRef<MemberList>, |
|
members_drop_guard: OnceCell<EventHandlerDropGuard>, |
|
/// The number of joined members in the room, according to the |
|
/// homeserver. |
|
#[property(get)] |
|
joined_members_count: Cell<u64>, |
|
/// The member corresponding to our own user. |
|
#[property(get)] |
|
own_member: OnceCell<Member>, |
|
/// The user who sent the invite to this room. |
|
/// |
|
/// This is only set when this room is an invitation. |
|
#[property(get)] |
|
inviter: RefCell<Option<Member>>, |
|
/// The other member of the room, if this room is a direct chat and |
|
/// there is only one other member. |
|
#[property(get)] |
|
direct_member: RefCell<Option<Member>>, |
|
/// The timeline of this room. |
|
#[property(get)] |
|
timeline: OnceCell<Timeline>, |
|
/// The timestamp of the room's latest activity. |
|
/// |
|
/// This is the timestamp of the latest event that counts as possibly |
|
/// unread. |
|
/// |
|
/// If it is not known, it will return `0`. |
|
#[property(get)] |
|
latest_activity: Cell<u64>, |
|
/// Whether all messages of this room are read. |
|
#[property(get)] |
|
is_read: Cell<bool>, |
|
/// The number of unread notifications of this room. |
|
#[property(get)] |
|
notification_count: Cell<u64>, |
|
/// whether this room has unread notifications. |
|
#[property(get)] |
|
has_notifications: Cell<bool>, |
|
/// The highlight state of the room. |
|
#[property(get)] |
|
highlight: Cell<HighlightFlags>, |
|
/// Whether this room is encrypted. |
|
#[property(get)] |
|
is_encrypted: Cell<bool>, |
|
/// The join rule of this room. |
|
#[property(get)] |
|
join_rule: JoinRule, |
|
/// Whether guests are allowed. |
|
#[property(get)] |
|
guests_allowed: Cell<bool>, |
|
/// The visibility of the history. |
|
#[property(get, builder(HistoryVisibilityValue::default()))] |
|
history_visibility: Cell<HistoryVisibilityValue>, |
|
/// The version of this room. |
|
#[property(get = Self::version)] |
|
version: PhantomData<String>, |
|
/// Whether this room is federated. |
|
#[property(get = Self::federated)] |
|
federated: PhantomData<bool>, |
|
/// The list of members currently typing in this room. |
|
#[property(get)] |
|
typing_list: TypingList, |
|
typing_drop_guard: OnceCell<EventHandlerDropGuard>, |
|
/// The notifications settings for this room. |
|
#[property(get, set = Self::set_notifications_setting, explicit_notify, builder(NotificationsRoomSetting::default()))] |
|
notifications_setting: Cell<NotificationsRoomSetting>, |
|
/// The permissions of our own user in this room |
|
#[property(get)] |
|
permissions: Permissions, |
|
/// An ongoing identity verification in this room. |
|
#[property(get, set = Self::set_verification, nullable, explicit_notify)] |
|
verification: BoundObjectWeakRef<IdentityVerification>, |
|
/// Whether the room info is initialized. |
|
/// |
|
/// Used to silence logs during initialization. |
|
is_room_info_initialized: Cell<bool>, |
|
} |
|
|
|
#[glib::object_subclass] |
|
impl ObjectSubclass for Room { |
|
const NAME: &'static str = "Room"; |
|
type Type = super::Room; |
|
type ParentType = PillSource; |
|
} |
|
|
|
#[glib::derived_properties] |
|
impl ObjectImpl for Room { |
|
fn signals() -> &'static [Signal] { |
|
static SIGNALS: Lazy<Vec<Signal>> = |
|
Lazy::new(|| vec![Signal::builder("room-forgotten").build()]); |
|
SIGNALS.as_ref() |
|
} |
|
} |
|
|
|
impl PillSourceImpl for Room { |
|
fn identifier(&self) -> String { |
|
self.aliases |
|
.alias_string() |
|
.unwrap_or_else(|| self.room_id_string()) |
|
} |
|
} |
|
|
|
impl Room { |
|
/// Initialize this room. |
|
pub(super) fn init(&self, matrix_room: MatrixRoom, metainfo: Option<RoomMetainfo>) { |
|
let obj = self.obj(); |
|
|
|
self.matrix_room |
|
.set(matrix_room) |
|
.expect("matrix room is uninitialized"); |
|
|
|
self.aliases.init(&obj); |
|
self.load_predecessor(); |
|
self.watch_members(); |
|
self.init_timeline(); |
|
self.join_rule.init(&obj); |
|
self.set_up_typing(); |
|
|
|
spawn!( |
|
glib::Priority::DEFAULT_IDLE, |
|
clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.update_with_room_info(imp.matrix_room().clone_info()) |
|
.await; |
|
imp.watch_room_info(); |
|
imp.is_room_info_initialized.set(true); |
|
} |
|
) |
|
); |
|
|
|
spawn!( |
|
glib::Priority::DEFAULT_IDLE, |
|
clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.load_own_member().await; |
|
} |
|
) |
|
); |
|
|
|
spawn!( |
|
glib::Priority::DEFAULT_IDLE, |
|
clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.permissions.init(&imp.obj()).await; |
|
} |
|
) |
|
); |
|
|
|
spawn!( |
|
glib::Priority::DEFAULT_IDLE, |
|
clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.watch_send_queue().await; |
|
} |
|
) |
|
); |
|
|
|
if let Some(RoomMetainfo { |
|
latest_activity, |
|
is_read, |
|
}) = metainfo |
|
{ |
|
self.set_latest_activity(latest_activity); |
|
self.set_is_read(is_read); |
|
|
|
self.update_highlight(); |
|
} |
|
} |
|
|
|
/// The room API of the SDK. |
|
pub(super) fn matrix_room(&self) -> &MatrixRoom { |
|
self.matrix_room.get().expect("matrix room was initialized") |
|
} |
|
|
|
/// Set the current session |
|
fn set_session(&self, session: Session) { |
|
self.session.set(Some(&session)); |
|
|
|
let own_member = Member::new(&self.obj(), session.user_id().clone()); |
|
self.own_member |
|
.set(own_member) |
|
.expect("own member was uninitialized"); |
|
} |
|
|
|
/// The ID of this room. |
|
pub fn room_id(&self) -> &RoomId { |
|
self.matrix_room().room_id() |
|
} |
|
|
|
/// The ID of this room, as a string. |
|
fn room_id_string(&self) -> String { |
|
self.matrix_room().room_id().to_string() |
|
} |
|
|
|
/// Update the name of this room. |
|
fn update_name(&self) { |
|
let name = self |
|
.matrix_room() |
|
.name() |
|
.map(|mut s| { |
|
s.truncate_end_whitespaces(); |
|
s |
|
}) |
|
.filter(|s| !s.is_empty()); |
|
|
|
if *self.name.borrow() == name { |
|
return; |
|
} |
|
|
|
self.name.replace(name); |
|
self.obj().notify_name(); |
|
} |
|
|
|
/// Load the display name from the SDK. |
|
async fn update_display_name(&self) { |
|
let matrix_room = self.matrix_room(); |
|
|
|
let sdk_display_name = if let Some(sdk_display_name) = matrix_room.cached_display_name() |
|
{ |
|
Some(sdk_display_name) |
|
} else { |
|
let matrix_room = matrix_room.clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.compute_display_name().await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(sdk_display_name) => Some(sdk_display_name), |
|
Err(error) => { |
|
error!("Could not compute display name: {error}"); |
|
None |
|
} |
|
} |
|
}; |
|
|
|
let mut display_name = if let Some(sdk_display_name) = sdk_display_name { |
|
match sdk_display_name { |
|
DisplayName::Named(s) |
|
| DisplayName::Calculated(s) |
|
| DisplayName::Aliased(s) => s, |
|
// Translators: This is the name of a room that is empty but had another |
|
// user before. Do NOT translate the content between |
|
// '{' and '}', this is a variable name. |
|
DisplayName::EmptyWas(s) => { |
|
gettext_f("Empty Room (was {user})", &[("user", &s)]) |
|
} |
|
// Translators: This is the name of a room without other users. |
|
DisplayName::Empty => gettext("Empty Room"), |
|
} |
|
} else { |
|
Default::default() |
|
}; |
|
|
|
display_name.truncate_end_whitespaces(); |
|
|
|
if display_name.is_empty() { |
|
// Translators: This is displayed when the room name is unknown yet. |
|
display_name = gettext("Unknown"); |
|
} |
|
|
|
self.obj().set_display_name(display_name); |
|
} |
|
|
|
/// Set whether this room has an avatar explicitly set. |
|
fn set_has_avatar(&self, has_avatar: bool) { |
|
if self.has_avatar.get() == has_avatar { |
|
return; |
|
} |
|
|
|
self.has_avatar.set(has_avatar); |
|
self.obj().notify_has_avatar(); |
|
} |
|
|
|
/// Update the avatar of the room. |
|
fn update_avatar(&self) { |
|
let Some(session) = self.session.upgrade() else { |
|
return; |
|
}; |
|
|
|
let obj = self.obj(); |
|
let avatar_data = obj.avatar_data(); |
|
let matrix_room = self.matrix_room(); |
|
|
|
let prev_avatar_url = avatar_data.image().and_then(|i| i.uri()); |
|
let room_avatar_url = matrix_room.avatar_url(); |
|
|
|
if prev_avatar_url.is_some() && prev_avatar_url == room_avatar_url { |
|
// The avatar did not change. |
|
return; |
|
} |
|
|
|
if let Some(avatar_url) = room_avatar_url { |
|
// The avatar has changed, update it. |
|
let avatar_info = matrix_room.avatar_info(); |
|
|
|
if let Some(avatar_image) = avatar_data |
|
.image() |
|
.filter(|i| i.uri_source() == AvatarUriSource::Room) |
|
{ |
|
avatar_image.set_uri_and_info(Some(avatar_url), avatar_info); |
|
} else { |
|
let avatar_image = AvatarImage::new( |
|
&session, |
|
AvatarUriSource::Room, |
|
Some(avatar_url), |
|
avatar_info, |
|
); |
|
|
|
avatar_data.set_image(Some(avatar_image.clone())); |
|
} |
|
|
|
self.set_has_avatar(true); |
|
return; |
|
}; |
|
|
|
self.set_has_avatar(false); |
|
|
|
// If we have a direct member, use their avatar. |
|
if let Some(direct_member) = self.direct_member.borrow().as_ref() { |
|
avatar_data.set_image(direct_member.avatar_data().image()); |
|
} |
|
|
|
let avatar_image = avatar_data.image(); |
|
|
|
if let Some(avatar_image) = avatar_image |
|
.as_ref() |
|
.filter(|i| i.uri_source() == AvatarUriSource::Room) |
|
{ |
|
// The room has no avatar, make sure we remove it. |
|
avatar_image.set_uri_and_info(None, None); |
|
} else if avatar_image.is_none() { |
|
// We always need an avatar image, even if it is empty. |
|
avatar_data.set_image(Some(AvatarImage::new( |
|
&session, |
|
AvatarUriSource::Room, |
|
None, |
|
None, |
|
))) |
|
} |
|
} |
|
|
|
/// Update the topic of this room. |
|
fn update_topic(&self) { |
|
let topic = self |
|
.matrix_room() |
|
.topic() |
|
.map(|mut s| { |
|
s.truncate_end_whitespaces(); |
|
s |
|
}) |
|
.filter(|topic| !topic.is_empty()); |
|
|
|
if *self.topic.borrow() == topic { |
|
return; |
|
} |
|
|
|
let topic_linkified = topic.as_ref().map(|t| { |
|
// Detect links. |
|
let mut s = linkify(t); |
|
// Remove trailing spaces. |
|
s.truncate_end_whitespaces(); |
|
s |
|
}); |
|
|
|
self.topic.replace(topic); |
|
self.topic_linkified.replace(topic_linkified); |
|
|
|
let obj = self.obj(); |
|
obj.notify_topic(); |
|
obj.notify_topic_linkified(); |
|
} |
|
|
|
/// Set the category of this room. |
|
pub(super) fn set_category(&self, category: RoomCategory) { |
|
let old_category = self.category.get(); |
|
|
|
if old_category == RoomCategory::Outdated || old_category == category { |
|
return; |
|
} |
|
|
|
self.category.set(category); |
|
self.obj().notify_category(); |
|
|
|
// Check if the previous state was different. |
|
let room_state = self.matrix_room().state(); |
|
if !old_category.is_state(room_state) { |
|
if self.is_room_info_initialized.get() { |
|
debug!(room_id = %self.room_id(), ?room_state, "The state of the room changed"); |
|
} |
|
|
|
match room_state { |
|
RoomState::Joined => { |
|
if let Some(members) = self.members.upgrade() { |
|
// If we where invited or left before, the list was likely not completed |
|
// or might have changed. |
|
members.reload(); |
|
} |
|
|
|
self.set_up_typing(); |
|
} |
|
RoomState::Left | RoomState::Knocked => {} |
|
RoomState::Invited => { |
|
spawn!( |
|
glib::Priority::DEFAULT_IDLE, |
|
clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.load_inviter().await; |
|
} |
|
) |
|
); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/// Update the category from the SDK. |
|
pub(super) fn update_category(&self) { |
|
// Don't load the category if this room was upgraded |
|
if self.category.get() == RoomCategory::Outdated { |
|
return; |
|
} |
|
|
|
if self |
|
.inviter |
|
.borrow() |
|
.as_ref() |
|
.is_some_and(|i| i.is_ignored()) |
|
{ |
|
self.set_category(RoomCategory::Ignored); |
|
return; |
|
} |
|
|
|
let matrix_room = self.matrix_room(); |
|
let category = match matrix_room.state() { |
|
RoomState::Joined => { |
|
if matrix_room.is_space() { |
|
RoomCategory::Space |
|
} else if matrix_room.is_favourite() { |
|
RoomCategory::Favorite |
|
} else if matrix_room.is_low_priority() { |
|
RoomCategory::LowPriority |
|
} else { |
|
RoomCategory::Normal |
|
} |
|
} |
|
RoomState::Invited => RoomCategory::Invited, |
|
RoomState::Left | RoomState::Knocked => RoomCategory::Left, |
|
}; |
|
|
|
self.set_category(category); |
|
} |
|
|
|
/// Set whether this room is a direct chat. |
|
async fn set_is_direct(&self, is_direct: bool) { |
|
if self.is_direct.get() == is_direct { |
|
return; |
|
} |
|
|
|
self.is_direct.set(is_direct); |
|
self.obj().notify_is_direct(); |
|
|
|
self.update_direct_member().await; |
|
} |
|
|
|
/// Update whether the room is direct or not. |
|
pub(super) async fn update_is_direct(&self) { |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.is_direct().await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(is_direct) => self.set_is_direct(is_direct).await, |
|
Err(error) => { |
|
error!(room_id = %self.room_id(), "Could not load whether room is direct: {error}"); |
|
} |
|
} |
|
} |
|
|
|
/// Update the tombstone for this room. |
|
fn update_tombstone(&self) { |
|
let matrix_room = self.matrix_room(); |
|
|
|
if !matrix_room.is_tombstoned() || self.successor_id.get().is_some() { |
|
return; |
|
} |
|
let obj = self.obj(); |
|
|
|
if let Some(room_tombstone) = matrix_room.tombstone() { |
|
self.successor_id |
|
.set(room_tombstone.replacement_room) |
|
.expect("successor ID is uninitialized"); |
|
obj.notify_successor_id_string(); |
|
}; |
|
|
|
// Try to get the successor. |
|
self.update_successor(); |
|
|
|
// If the successor was not found, watch for it in the room list. |
|
if self.successor.upgrade().is_none() { |
|
if let Some(session) = self.session.upgrade() { |
|
session |
|
.room_list() |
|
.add_tombstoned_room(self.room_id().to_owned()); |
|
} |
|
} |
|
|
|
if !self.is_tombstoned.get() { |
|
self.is_tombstoned.set(true); |
|
obj.notify_is_tombstoned(); |
|
} |
|
} |
|
|
|
/// Update the successor of this room. |
|
pub(super) fn update_successor(&self) { |
|
if self.category.get() == RoomCategory::Outdated { |
|
return; |
|
} |
|
|
|
let Some(session) = self.session.upgrade() else { |
|
return; |
|
}; |
|
let room_list = session.room_list(); |
|
|
|
if let Some(successor) = self |
|
.successor_id |
|
.get() |
|
.and_then(|successor_id| room_list.get(successor_id)) |
|
{ |
|
// The Matrix spec says that we should use the "predecessor" field of the |
|
// m.room.create event of the successor, not the "successor" field of the |
|
// m.room.tombstone event, so check it just to be sure. |
|
if let Some(predecessor_id) = successor.predecessor_id() { |
|
if predecessor_id == self.room_id() { |
|
self.set_successor(&successor); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
// The tombstone event can be redacted and we lose the successor, so search in |
|
// the room predecessors of other rooms. |
|
for room in room_list.iter::<super::Room>() { |
|
let Ok(room) = room else { |
|
break; |
|
}; |
|
|
|
if let Some(predecessor_id) = room.predecessor_id() { |
|
if predecessor_id == self.room_id() { |
|
self.set_successor(&room); |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
/// The ID of the room that was upgraded and that this one replaces, as |
|
/// a string. |
|
fn predecessor_id_string(&self) -> Option<String> { |
|
self.predecessor_id.get().map(ToString::to_string) |
|
} |
|
|
|
/// Load the predecessor of this room. |
|
fn load_predecessor(&self) { |
|
let Some(event) = self.matrix_room().create_content() else { |
|
return; |
|
}; |
|
let Some(predecessor) = event.predecessor else { |
|
return; |
|
}; |
|
|
|
self.predecessor_id |
|
.set(predecessor.room_id) |
|
.expect("predecessor ID is uninitialized"); |
|
self.obj().notify_predecessor_id_string(); |
|
} |
|
|
|
/// The ID of the successor of this room, if this room was upgraded. |
|
fn successor_id_string(&self) -> Option<String> { |
|
self.successor_id.get().map(ToString::to_string) |
|
} |
|
|
|
/// Set the successor of this room. |
|
fn set_successor(&self, successor: &super::Room) { |
|
self.successor.set(Some(successor)); |
|
self.obj().notify_successor(); |
|
|
|
self.set_category(RoomCategory::Outdated); |
|
} |
|
|
|
/// Watch changes in the members list. |
|
fn watch_members(&self) { |
|
let matrix_room = self.matrix_room(); |
|
|
|
let obj_weak = glib::SendWeakRef::from(self.obj().downgrade()); |
|
let handle = matrix_room.add_event_handler(move |event: SyncRoomMemberEvent| { |
|
let obj_weak = obj_weak.clone(); |
|
async move { |
|
let ctx = glib::MainContext::default(); |
|
ctx.spawn(async move { |
|
spawn!(async move { |
|
if let Some(obj) = obj_weak.upgrade() { |
|
obj.imp().handle_member_event(event); |
|
} |
|
}); |
|
}); |
|
} |
|
}); |
|
|
|
let drop_guard = matrix_room.client().event_handler_drop_guard(handle); |
|
self.members_drop_guard.set(drop_guard).unwrap(); |
|
} |
|
|
|
/// Handle a member event received via sync |
|
fn handle_member_event(&self, event: SyncRoomMemberEvent) { |
|
let user_id = event.state_key(); |
|
|
|
if let Some(members) = self.members.upgrade() { |
|
members.update_member(user_id.clone()); |
|
} else if user_id == self.own_member().user_id() { |
|
self.own_member().update(); |
|
} else if self |
|
.direct_member |
|
.borrow() |
|
.as_ref() |
|
.is_some_and(|member| member.user_id() == user_id) |
|
{ |
|
if let Some(member) = self.direct_member.borrow().as_ref() { |
|
member.update(); |
|
} |
|
} |
|
|
|
// It might change the direct member if the number of members changed. |
|
spawn!(clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
async move { |
|
imp.update_direct_member().await; |
|
} |
|
)); |
|
} |
|
|
|
/// Set the number of joined members in the room, according to the |
|
/// homeserver. |
|
fn set_joined_members_count(&self, count: u64) { |
|
if self.joined_members_count.get() == count { |
|
return; |
|
} |
|
|
|
self.joined_members_count.set(count); |
|
self.obj().notify_joined_members_count(); |
|
} |
|
|
|
/// The member corresponding to our own user. |
|
fn own_member(&self) -> &Member { |
|
self.own_member.get().expect("Own member was initialized") |
|
} |
|
|
|
/// Load our own member from the store. |
|
async fn load_own_member(&self) { |
|
let own_member = self.own_member(); |
|
let user_id = own_member.user_id().clone(); |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let handle = |
|
spawn_tokio!(async move { matrix_room.get_member_no_sync(&user_id).await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(Some(matrix_member)) => own_member.update_from_room_member(&matrix_member), |
|
Ok(None) => {} |
|
Err(error) => error!( |
|
"Could not load own member for room {}: {error}", |
|
self.room_id() |
|
), |
|
} |
|
} |
|
|
|
/// Load the member that invited us to this room, when applicable. |
|
async fn load_inviter(&self) { |
|
let matrix_room = self.matrix_room(); |
|
|
|
if matrix_room.state() != RoomState::Invited { |
|
return; |
|
} |
|
|
|
let matrix_room_clone = matrix_room.clone(); |
|
let handle = spawn_tokio!(async move { matrix_room_clone.invite_details().await }); |
|
|
|
let invite = match handle.await.expect("task was not aborted") { |
|
Ok(invite) => invite, |
|
Err(error) => { |
|
error!("Could not get invite: {error}"); |
|
return; |
|
} |
|
}; |
|
|
|
let Some(inviter_member) = invite.inviter else { |
|
return; |
|
}; |
|
|
|
let inviter = Member::new(&self.obj(), inviter_member.user_id().to_owned()); |
|
inviter.update_from_room_member(&inviter_member); |
|
|
|
inviter |
|
.upcast_ref::<User>() |
|
.connect_is_ignored_notify(clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
move |_| { |
|
// When the user is ignored, this invite should be ignored too. |
|
imp.update_category(); |
|
} |
|
)); |
|
|
|
self.inviter.replace(Some(inviter)); |
|
|
|
self.obj().notify_inviter(); |
|
self.update_category(); |
|
} |
|
|
|
/// Set the other member of the room, if this room is a direct chat and |
|
/// there is only one other member.. |
|
fn set_direct_member(&self, member: Option<Member>) { |
|
if *self.direct_member.borrow() == member { |
|
return; |
|
} |
|
|
|
self.direct_member.replace(member); |
|
self.obj().notify_direct_member(); |
|
self.update_avatar(); |
|
} |
|
|
|
/// The ID of the other user, if this is a direct chat and there is only |
|
/// one other user. |
|
async fn direct_user_id(&self) -> Option<OwnedUserId> { |
|
let matrix_room = self.matrix_room(); |
|
|
|
// Check if the room direct and if there only one target. |
|
let direct_targets = matrix_room.direct_targets(); |
|
if direct_targets.len() != 1 { |
|
// It was a direct chat with several users. |
|
return None; |
|
} |
|
|
|
let direct_target_user_id = direct_targets |
|
.into_iter() |
|
.next() |
|
.expect("there is 1 direct target"); |
|
|
|
// Check that there are still at most 2 members. |
|
let members_count = matrix_room.active_members_count(); |
|
|
|
if members_count > 2 { |
|
// We only want a 1-to-1 room. The count might be 1 if the other user left, but |
|
// we can reinvite them. |
|
return None; |
|
} |
|
|
|
// Check that the members count is correct. It might not be correct if the room |
|
// was just joined, or if it is in an invited state. |
|
let matrix_room_clone = matrix_room.clone(); |
|
let handle = |
|
spawn_tokio!( |
|
async move { matrix_room_clone.members(RoomMemberships::ACTIVE).await } |
|
); |
|
|
|
let members = match handle.await.expect("task was not aborted") { |
|
Ok(m) => m, |
|
Err(error) => { |
|
error!("Could not load room members: {error}"); |
|
vec![] |
|
} |
|
}; |
|
|
|
let members_count = members_count.max(members.len() as u64); |
|
if members_count > 2 { |
|
// Same as before. |
|
return None; |
|
} |
|
|
|
let own_user_id = matrix_room.own_user_id(); |
|
// Get the other member from the list. |
|
for member in members { |
|
let user_id = member.user_id(); |
|
|
|
if user_id != direct_target_user_id && user_id != own_user_id { |
|
// There is a non-direct member. |
|
return None; |
|
} |
|
} |
|
|
|
Some(direct_target_user_id) |
|
} |
|
|
|
/// Update the other member of the room, if this room is a direct chat |
|
/// and there is only one other member. |
|
async fn update_direct_member(&self) { |
|
let Some(direct_user_id) = self.direct_user_id().await else { |
|
self.set_direct_member(None); |
|
return; |
|
}; |
|
|
|
if self |
|
.direct_member |
|
.borrow() |
|
.as_ref() |
|
.is_some_and(|m| *m.user_id() == direct_user_id) |
|
{ |
|
// Already up-to-date. |
|
return; |
|
} |
|
|
|
let direct_member = if let Some(members) = self.members.upgrade() { |
|
members.get_or_create(direct_user_id.clone()) |
|
} else { |
|
Member::new(&self.obj(), direct_user_id.clone()) |
|
}; |
|
|
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = |
|
spawn_tokio!(async move { matrix_room.get_member_no_sync(&direct_user_id).await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(Some(matrix_member)) => { |
|
direct_member.update_from_room_member(&matrix_member); |
|
} |
|
Ok(None) => {} |
|
Err(error) => { |
|
error!("Could not get direct member: {error}"); |
|
} |
|
} |
|
|
|
self.set_direct_member(Some(direct_member)); |
|
} |
|
|
|
/// Initialize the timeline of this room. |
|
fn init_timeline(&self) { |
|
let timeline = self.timeline.get_or_init(|| Timeline::new(&self.obj())); |
|
|
|
timeline.connect_read_change_trigger(clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
move |_| { |
|
spawn!(glib::Priority::DEFAULT_IDLE, async move { |
|
imp.handle_read_change_trigger().await |
|
}); |
|
} |
|
)); |
|
|
|
if !matches!( |
|
self.category.get(), |
|
RoomCategory::Left | RoomCategory::Outdated |
|
) { |
|
// Load the room history when idle. |
|
spawn!( |
|
glib::source::Priority::LOW, |
|
clone!( |
|
#[weak] |
|
timeline, |
|
async move { |
|
// Make a single request for now. |
|
timeline.load(|| ControlFlow::Break(())).await; |
|
} |
|
) |
|
); |
|
} |
|
} |
|
|
|
/// Set the timestamp of the room's latest possibly unread event. |
|
pub(super) fn set_latest_activity(&self, latest_activity: u64) { |
|
if self.latest_activity.get() == latest_activity { |
|
return; |
|
} |
|
|
|
self.latest_activity.set(latest_activity); |
|
self.obj().notify_latest_activity(); |
|
} |
|
|
|
/// Set whether all messages of this room are read. |
|
fn set_is_read(&self, is_read: bool) { |
|
if self.is_read.get() == is_read { |
|
return; |
|
} |
|
|
|
self.is_read.set(is_read); |
|
self.obj().notify_is_read(); |
|
} |
|
|
|
/// Handle the trigger emitted when a read change might have occurred. |
|
async fn handle_read_change_trigger(&self) { |
|
let timeline = self.timeline.get().expect("timeline is initialized"); |
|
|
|
if let Some(has_unread) = timeline.has_unread_messages().await { |
|
self.set_is_read(!has_unread); |
|
} |
|
|
|
self.update_highlight(); |
|
} |
|
|
|
/// Set how this room is highlighted. |
|
fn set_highlight(&self, highlight: HighlightFlags) { |
|
if self.highlight.get() == highlight { |
|
return; |
|
} |
|
|
|
self.highlight.set(highlight); |
|
self.obj().notify_highlight(); |
|
} |
|
|
|
/// Update the highlight of the room from the current state. |
|
fn update_highlight(&self) { |
|
let mut highlight = HighlightFlags::empty(); |
|
|
|
if matches!(self.category.get(), RoomCategory::Left) { |
|
// Consider that all left rooms are read. |
|
self.set_highlight(highlight); |
|
self.set_notification_count(0); |
|
return; |
|
} |
|
|
|
if !self.is_read.get() { |
|
let counts = self.matrix_room().unread_notification_counts(); |
|
|
|
if counts.highlight_count > 0 { |
|
highlight = HighlightFlags::all(); |
|
} else { |
|
highlight = HighlightFlags::BOLD; |
|
} |
|
self.set_notification_count(counts.notification_count); |
|
} else { |
|
self.set_notification_count(0); |
|
} |
|
|
|
self.set_highlight(highlight); |
|
} |
|
|
|
/// Set the number of unread notifications of this room. |
|
fn set_notification_count(&self, count: u64) { |
|
if self.notification_count.get() == count { |
|
return; |
|
} |
|
|
|
self.notification_count.set(count); |
|
self.set_has_notifications(count > 0); |
|
self.obj().notify_notification_count(); |
|
} |
|
|
|
/// Set whether this room has unread notifications. |
|
fn set_has_notifications(&self, has_notifications: bool) { |
|
if self.has_notifications.get() == has_notifications { |
|
return; |
|
} |
|
|
|
self.has_notifications.set(has_notifications); |
|
self.obj().notify_has_notifications(); |
|
} |
|
|
|
/// Update whether the room is encrypted from the SDK. |
|
async fn update_is_encrypted(&self) { |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.is_encrypted().await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(true) => { |
|
self.is_encrypted.set(true); |
|
self.obj().notify_is_encrypted(); |
|
} |
|
Ok(false) => { |
|
// Ignore as the room encryption cannot be disabled. |
|
} |
|
Err(error) => { |
|
error!("Could not load room encryption state: {error}"); |
|
} |
|
} |
|
} |
|
|
|
/// Update whether guests are allowed. |
|
fn update_guests_allowed(&self) { |
|
let matrix_room = self.matrix_room(); |
|
let guests_allowed = matrix_room.guest_access() == GuestAccess::CanJoin; |
|
|
|
if self.guests_allowed.get() == guests_allowed { |
|
return; |
|
} |
|
|
|
self.guests_allowed.set(guests_allowed); |
|
self.obj().notify_guests_allowed(); |
|
} |
|
|
|
/// Update the visibility of the history. |
|
fn update_history_visibility(&self) { |
|
let matrix_room = self.matrix_room(); |
|
let visibility = matrix_room.history_visibility().into(); |
|
|
|
if self.history_visibility.get() == visibility { |
|
return; |
|
} |
|
|
|
self.history_visibility.set(visibility); |
|
self.obj().notify_history_visibility(); |
|
} |
|
|
|
/// The version of this room. |
|
fn version(&self) -> String { |
|
self.matrix_room() |
|
.create_content() |
|
.map(|c| c.room_version.to_string()) |
|
.unwrap_or_default() |
|
} |
|
|
|
/// Whether this room is federated. |
|
fn federated(&self) -> bool { |
|
self.matrix_room() |
|
.create_content() |
|
.map(|c| c.federate) |
|
.unwrap_or_default() |
|
} |
|
|
|
/// Start listening to typing events. |
|
fn set_up_typing(&self) { |
|
if self.typing_drop_guard.get().is_some() { |
|
// The event handler is already set up. |
|
return; |
|
} |
|
|
|
let matrix_room = self.matrix_room(); |
|
if matrix_room.state() != RoomState::Joined { |
|
return; |
|
}; |
|
|
|
let (typing_drop_guard, receiver) = matrix_room.subscribe_to_typing_notifications(); |
|
let stream = BroadcastStream::new(receiver); |
|
|
|
let obj_weak = glib::SendWeakRef::from(self.obj().downgrade()); |
|
let fut = stream.for_each(move |typing_user_ids| { |
|
let obj_weak = obj_weak.clone(); |
|
async move { |
|
let Ok(typing_user_ids) = typing_user_ids else { |
|
return; |
|
}; |
|
|
|
let ctx = glib::MainContext::default(); |
|
ctx.spawn(async move { |
|
spawn!(async move { |
|
if let Some(obj) = obj_weak.upgrade() { |
|
obj.imp().update_typing_list(typing_user_ids); |
|
} |
|
}); |
|
}); |
|
} |
|
}); |
|
spawn_tokio!(fut); |
|
|
|
self.typing_drop_guard |
|
.set(typing_drop_guard) |
|
.expect("typing drop guard is uninitialized"); |
|
} |
|
|
|
/// Update the typing list with the given user IDs. |
|
fn update_typing_list(&self, typing_user_ids: Vec<OwnedUserId>) { |
|
let Some(session) = self.session.upgrade() else { |
|
return; |
|
}; |
|
|
|
let Some(members) = self.members.upgrade() else { |
|
// If we don't have a members list, the room is not shown so we don't need to |
|
// update the typing list. |
|
self.typing_list.update(vec![]); |
|
return; |
|
}; |
|
|
|
let own_user_id = session.user_id(); |
|
|
|
let members = typing_user_ids |
|
.into_iter() |
|
.filter(|user_id| user_id != own_user_id) |
|
.map(|user_id| members.get_or_create(user_id)) |
|
.collect(); |
|
|
|
self.typing_list.update(members); |
|
} |
|
|
|
/// Set the notifications setting for this room. |
|
fn set_notifications_setting(&self, setting: NotificationsRoomSetting) { |
|
if self.notifications_setting.get() == setting { |
|
return; |
|
} |
|
|
|
self.notifications_setting.set(setting); |
|
self.obj().notify_notifications_setting(); |
|
} |
|
|
|
/// Set an ongoing verification in this room. |
|
fn set_verification(&self, verification: Option<IdentityVerification>) { |
|
if self.verification.obj().is_some() && verification.is_some() { |
|
// Just keep the same verification until it is dropped. Then we will look if |
|
// there is an ongoing verification in the room. |
|
return; |
|
} |
|
|
|
self.verification.disconnect_signals(); |
|
|
|
let verification = verification.or_else(|| { |
|
// Look if there is an ongoing verification to replace it with. |
|
let room_id = self.matrix_room().room_id(); |
|
self.session |
|
.upgrade() |
|
.map(|s| s.verification_list()) |
|
.and_then(|list| list.ongoing_room_verification(room_id)) |
|
}); |
|
|
|
if let Some(verification) = &verification { |
|
let state_handler = verification.connect_is_finished_notify(clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
move |_| { |
|
imp.set_verification(None); |
|
} |
|
)); |
|
|
|
let dismiss_handler = verification.connect_dismiss(clone!( |
|
#[weak(rename_to = imp)] |
|
self, |
|
move |_| { |
|
imp.set_verification(None); |
|
} |
|
)); |
|
|
|
self.verification |
|
.set(verification, vec![state_handler, dismiss_handler]); |
|
} |
|
|
|
self.obj().notify_verification(); |
|
} |
|
|
|
/// Watch the SDK's room info for changes to the room state. |
|
fn watch_room_info(&self) { |
|
let matrix_room = self.matrix_room(); |
|
let subscriber = matrix_room.subscribe_info(); |
|
|
|
let obj_weak = glib::SendWeakRef::from(self.obj().downgrade()); |
|
let fut = subscriber.for_each(move |room_info| { |
|
let obj_weak = obj_weak.clone(); |
|
async move { |
|
let ctx = glib::MainContext::default(); |
|
ctx.spawn(async move { |
|
spawn!(async move { |
|
if let Some(obj) = obj_weak.upgrade() { |
|
obj.imp().update_with_room_info(room_info).await; |
|
} |
|
}); |
|
}); |
|
} |
|
}); |
|
spawn_tokio!(fut); |
|
} |
|
|
|
/// Update this room with the given SDK room info. |
|
async fn update_with_room_info(&self, room_info: RoomInfo) { |
|
self.aliases.update(); |
|
self.update_name(); |
|
self.update_display_name().await; |
|
self.update_avatar(); |
|
self.update_topic(); |
|
self.update_category(); |
|
self.update_is_direct().await; |
|
self.update_tombstone(); |
|
self.set_joined_members_count(room_info.joined_members_count()); |
|
self.update_is_encrypted().await; |
|
self.join_rule.update(room_info.join_rule()); |
|
self.update_guests_allowed(); |
|
self.update_history_visibility(); |
|
} |
|
|
|
/// Handle changes in the ambiguity of members display names. |
|
pub(super) fn handle_ambiguity_changes<'a>( |
|
&self, |
|
changes: impl Iterator<Item = &'a AmbiguityChange>, |
|
) { |
|
// Use a set to make sure we update members only once. |
|
let user_ids = changes.flat_map(|c| c.user_ids()).collect::<HashSet<_>>(); |
|
|
|
if let Some(members) = self.members.upgrade() { |
|
for user_id in user_ids { |
|
members.update_member(user_id.to_owned()); |
|
} |
|
} else { |
|
let own_member = self.own_member(); |
|
let own_user_id = own_member.user_id(); |
|
|
|
if user_ids.contains(&**own_user_id) { |
|
own_member.update(); |
|
} |
|
} |
|
} |
|
|
|
/// Watch errors in the send queue to try to handle them. |
|
async fn watch_send_queue(&self) { |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let room_weak = glib::SendWeakRef::from(self.obj().downgrade()); |
|
spawn_tokio!(async move { |
|
let send_queue = matrix_room.send_queue(); |
|
let subscriber = match send_queue.subscribe().await { |
|
Ok((_, subscriber)) => BroadcastStream::new(subscriber), |
|
Err(error) => { |
|
warn!("Failed to listen to room send queue: {error}"); |
|
return; |
|
} |
|
}; |
|
|
|
subscriber |
|
.for_each(move |update| { |
|
let room_weak = room_weak.clone(); |
|
async move { |
|
let Ok(RoomSendQueueUpdate::SendError { |
|
error, |
|
is_recoverable: true, |
|
.. |
|
}) = update |
|
else { |
|
return; |
|
}; |
|
|
|
let ctx = glib::MainContext::default(); |
|
ctx.spawn(async move { |
|
spawn!(async move { |
|
let Some(obj) = room_weak.upgrade() else { |
|
return; |
|
}; |
|
let Some(session) = obj.session() else { |
|
return; |
|
}; |
|
|
|
if session.is_offline() { |
|
// The queue will be restarted when the session is back |
|
// online. |
|
return; |
|
} |
|
|
|
let duration = match error.client_api_error_kind() { |
|
Some(ErrorKind::LimitExceeded { |
|
retry_after: Some(retry_after), |
|
}) => match retry_after { |
|
RetryAfter::Delay(duration) => Some(*duration), |
|
RetryAfter::DateTime(time) => { |
|
time.duration_since(SystemTime::now()).ok() |
|
} |
|
}, |
|
_ => None, |
|
}; |
|
let retry_after = duration |
|
.and_then(|d| d.as_secs().try_into().ok()) |
|
.unwrap_or(DEFAULT_RETRY_AFTER); |
|
|
|
glib::timeout_add_seconds_local_once(retry_after, move || { |
|
let matrix_room = obj.matrix_room().clone(); |
|
// Getting a room's send queue requires a tokio executor. |
|
spawn_tokio!(async move { |
|
matrix_room.send_queue().set_enabled(true); |
|
}); |
|
}); |
|
}); |
|
}); |
|
} |
|
}) |
|
.await; |
|
}); |
|
} |
|
} |
|
} |
|
|
|
glib::wrapper! { |
|
/// GObject representation of a Matrix room. |
|
/// |
|
/// Handles populating the Timeline. |
|
pub struct Room(ObjectSubclass<imp::Room>) @extends PillSource; |
|
} |
|
|
|
impl Room { |
|
pub fn new(session: &Session, matrix_room: MatrixRoom, metainfo: Option<RoomMetainfo>) -> Self { |
|
let this = glib::Object::builder::<Self>() |
|
.property("session", session) |
|
.build(); |
|
|
|
this.imp().init(matrix_room, metainfo); |
|
this |
|
} |
|
|
|
/// The room API of the SDK. |
|
pub fn matrix_room(&self) -> &MatrixRoom { |
|
self.imp().matrix_room() |
|
} |
|
|
|
/// The ID of this room. |
|
pub fn room_id(&self) -> &RoomId { |
|
self.imp().room_id() |
|
} |
|
|
|
/// Get a human-readable ID for this `Room`. |
|
/// |
|
/// This shows the display name and room ID to identify the room easily in |
|
/// logs. |
|
pub fn human_readable_id(&self) -> String { |
|
format!("{} ({})", self.display_name(), self.room_id()) |
|
} |
|
|
|
/// The state of the room. |
|
pub fn state(&self) -> RoomState { |
|
self.matrix_room().state() |
|
} |
|
|
|
/// Whether this room is joined. |
|
pub fn is_joined(&self) -> bool { |
|
self.own_member().membership() == Membership::Join |
|
} |
|
|
|
/// The ID of the predecessor of this room, if this room is an upgrade to a |
|
/// previous room. |
|
pub fn predecessor_id(&self) -> Option<&OwnedRoomId> { |
|
self.imp().predecessor_id.get() |
|
} |
|
|
|
/// The ID of the successor of this Room, if this room was upgraded. |
|
pub fn successor_id(&self) -> Option<&RoomId> { |
|
self.imp().successor_id.get().map(std::ops::Deref::deref) |
|
} |
|
|
|
/// The `matrix.to` URI representation for this room. |
|
pub async fn matrix_to_uri(&self) -> MatrixToUri { |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let handle = spawn_tokio!(async move { matrix_room.matrix_to_permalink().await }); |
|
match handle.await.expect("task was not aborted") { |
|
Ok(permalink) => { |
|
return permalink; |
|
} |
|
Err(error) => { |
|
error!("Could not get room event permalink: {error}"); |
|
} |
|
} |
|
|
|
// Fallback to using just the room ID, without routing. |
|
self.room_id().matrix_to_uri() |
|
} |
|
|
|
/// The `matrix:` URI representation for this room. |
|
pub async fn matrix_uri(&self) -> MatrixUri { |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let handle = spawn_tokio!(async move { matrix_room.matrix_permalink(false).await }); |
|
match handle.await.expect("task was not aborted") { |
|
Ok(permalink) => { |
|
return permalink; |
|
} |
|
Err(error) => { |
|
error!("Could not get room event permalink: {error}"); |
|
} |
|
} |
|
|
|
// Fallback to using just the room ID, without routing. |
|
self.room_id().matrix_uri(false) |
|
} |
|
|
|
/// The `matrix.to` URI representation for the given event in this room. |
|
pub async fn matrix_to_event_uri(&self, event_id: OwnedEventId) -> MatrixToUri { |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let event_id_clone = event_id.clone(); |
|
let handle = |
|
spawn_tokio!( |
|
async move { matrix_room.matrix_to_event_permalink(event_id_clone).await } |
|
); |
|
match handle.await.expect("task was not aborted") { |
|
Ok(permalink) => { |
|
return permalink; |
|
} |
|
Err(error) => { |
|
error!("Could not get room event permalink: {error}"); |
|
} |
|
} |
|
|
|
// Fallback to using just the room ID, without routing. |
|
self.room_id().matrix_to_event_uri(event_id) |
|
} |
|
|
|
/// The `matrix:` URI representation for the given event in this room. |
|
pub async fn matrix_event_uri(&self, event_id: OwnedEventId) -> MatrixUri { |
|
let matrix_room = self.matrix_room().clone(); |
|
|
|
let event_id_clone = event_id.clone(); |
|
let handle = |
|
spawn_tokio!(async move { matrix_room.matrix_event_permalink(event_id_clone).await }); |
|
match handle.await.expect("task was not aborted") { |
|
Ok(permalink) => { |
|
return permalink; |
|
} |
|
Err(error) => { |
|
error!("Could not get room event permalink: {error}"); |
|
} |
|
} |
|
|
|
// Fallback to using just the room ID, without routing. |
|
self.room_id().matrix_event_uri(event_id) |
|
} |
|
|
|
/// Constructs an `AtRoom` for this room. |
|
pub fn at_room(&self) -> AtRoom { |
|
let at_room = AtRoom::new(self.room_id().to_owned()); |
|
|
|
// Bind the avatar image so it always looks the same. |
|
self.avatar_data() |
|
.bind_property("image", &at_room.avatar_data(), "image") |
|
.sync_create() |
|
.build(); |
|
|
|
at_room |
|
} |
|
|
|
/// Get or create the list of members of this room. |
|
/// |
|
/// This creates the [`MemberList`] if no strong reference to it exists. |
|
pub fn get_or_create_members(&self) -> MemberList { |
|
let members = &self.imp().members; |
|
if let Some(list) = members.upgrade() { |
|
list |
|
} else { |
|
let list = MemberList::new(self); |
|
members.set(Some(&list)); |
|
self.notify_members(); |
|
list |
|
} |
|
} |
|
|
|
/// Ensure the direct user of this room is an active member. |
|
/// |
|
/// If there is supposed to be a direct user in this room but they have left |
|
/// it, re-invite them. |
|
/// |
|
/// This is a noop if there is no supposed direct user or if the user is |
|
/// already an active member. |
|
pub async fn ensure_direct_user(&self) -> Result<(), ()> { |
|
let Some(member) = self.direct_member() else { |
|
warn!("Cannot ensure direct user in a room without direct target"); |
|
return Ok(()); |
|
}; |
|
|
|
if self.matrix_room().active_members_count() == 2 { |
|
return Ok(()); |
|
} |
|
|
|
self.invite(&[member.user_id().clone()]) |
|
.await |
|
.map_err(|_| ()) |
|
} |
|
|
|
/// Set the category of this room. |
|
/// |
|
/// This makes the necessary to propagate the category to the homeserver. |
|
/// |
|
/// Note: Rooms can't be moved to the invite category and they can't be |
|
/// moved once they are upgraded. |
|
pub async fn set_category(&self, category: RoomCategory) -> MatrixResult<()> { |
|
let previous_category = self.category(); |
|
|
|
if previous_category == category { |
|
return Ok(()); |
|
} |
|
|
|
if previous_category == RoomCategory::Outdated { |
|
warn!("Cannot change the category of an upgraded room"); |
|
return Ok(()); |
|
} |
|
|
|
match category { |
|
RoomCategory::Invited | RoomCategory::Space => { |
|
warn!("Rooms cannot be moved to the {category:?} category"); |
|
return Ok(()); |
|
} |
|
RoomCategory::Outdated | RoomCategory::Ignored => { |
|
// These are local-only categories. We do not need to propagate anything to the |
|
// server. |
|
self.imp().set_category(category); |
|
return Ok(()); |
|
} |
|
_ => {} |
|
} |
|
|
|
self.imp().set_category(category); |
|
|
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { |
|
let room_state = matrix_room.state(); |
|
|
|
match category { |
|
RoomCategory::Favorite => { |
|
if !matrix_room.is_favourite() { |
|
// This method handles removing the low priority tag. |
|
matrix_room.set_is_favourite(true, None).await?; |
|
} else if matrix_room.is_low_priority() { |
|
matrix_room.set_is_low_priority(false, None).await?; |
|
} |
|
|
|
if matches!(room_state, RoomState::Invited | RoomState::Left) { |
|
matrix_room.join().await?; |
|
} |
|
} |
|
RoomCategory::Normal => { |
|
if matrix_room.is_favourite() { |
|
matrix_room.set_is_favourite(false, None).await?; |
|
} |
|
if matrix_room.is_low_priority() { |
|
matrix_room.set_is_low_priority(false, None).await?; |
|
} |
|
|
|
if matches!(room_state, RoomState::Invited | RoomState::Left) { |
|
matrix_room.join().await?; |
|
} |
|
} |
|
RoomCategory::LowPriority => { |
|
if !matrix_room.is_low_priority() { |
|
// This method handles removing the favourite tag. |
|
matrix_room.set_is_low_priority(true, None).await?; |
|
} else if matrix_room.is_favourite() { |
|
matrix_room.set_is_favourite(false, None).await?; |
|
} |
|
|
|
if matches!(room_state, RoomState::Invited | RoomState::Left) { |
|
matrix_room.join().await?; |
|
} |
|
} |
|
RoomCategory::Left => { |
|
if matches!(room_state, RoomState::Invited | RoomState::Joined) { |
|
matrix_room.leave().await?; |
|
} |
|
} |
|
RoomCategory::Invited |
|
| RoomCategory::Outdated |
|
| RoomCategory::Space |
|
| RoomCategory::Ignored => unreachable!(), |
|
} |
|
|
|
Result::<_, matrix_sdk::Error>::Ok(()) |
|
}); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(_) => Ok(()), |
|
Err(error) => { |
|
error!("Could not set the room category: {error}"); |
|
|
|
// Reset the category |
|
self.imp().update_category(); |
|
|
|
Err(error) |
|
} |
|
} |
|
} |
|
|
|
/// Toggle the `key` reaction on the given related event in this room. |
|
pub async fn toggle_reaction(&self, key: String, event: &Event) -> Result<(), ()> { |
|
let timeline = self.timeline().matrix_timeline(); |
|
let event_timeline_id = event.timeline_id(); |
|
|
|
let handle = |
|
spawn_tokio!(async move { timeline.toggle_reaction(&event_timeline_id, &key).await }); |
|
|
|
if let Err(error) = handle.await.expect("task was not aborted") { |
|
error!("Could not toggle reaction: {error}"); |
|
return Err(()); |
|
} |
|
|
|
Ok(()) |
|
} |
|
|
|
/// Send the given receipt. |
|
pub async fn send_receipt(&self, receipt_type: ApiReceiptType, position: ReceiptPosition) { |
|
let Some(session) = self.session() else { |
|
return; |
|
}; |
|
let send_public_receipt = session.settings().public_read_receipts_enabled(); |
|
|
|
let receipt_type = match receipt_type { |
|
ApiReceiptType::Read if !send_public_receipt => ApiReceiptType::ReadPrivate, |
|
t => t, |
|
}; |
|
|
|
let matrix_timeline = self.timeline().matrix_timeline(); |
|
let handle = spawn_tokio!(async move { |
|
match position { |
|
ReceiptPosition::End => matrix_timeline.mark_as_read(receipt_type).await, |
|
ReceiptPosition::Event(event_id) => { |
|
matrix_timeline |
|
.send_single_receipt(receipt_type, ReceiptThread::Unthreaded, event_id) |
|
.await |
|
} |
|
} |
|
}); |
|
|
|
if let Err(error) = handle.await.expect("task was not aborted") { |
|
error!("Could not send read receipt: {error}"); |
|
} |
|
} |
|
|
|
/// Send a typing notification for this room, with the given typing state. |
|
pub fn send_typing_notification(&self, is_typing: bool) { |
|
let matrix_room = self.matrix_room(); |
|
if matrix_room.state() != RoomState::Joined { |
|
return; |
|
}; |
|
|
|
let matrix_room = matrix_room.clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.typing_notice(is_typing).await }); |
|
|
|
spawn!(glib::Priority::DEFAULT_IDLE, async move { |
|
match handle.await.expect("task was not aborted") { |
|
Ok(_) => {} |
|
Err(error) => error!("Could not send typing notification: {error}"), |
|
}; |
|
}); |
|
} |
|
|
|
/// Redact the given events in this room because of the given reason. |
|
/// |
|
/// Returns `Ok(())` if all the redactions are successful, otherwise |
|
/// returns the list of events that could not be redacted. |
|
pub async fn redact<'a>( |
|
&self, |
|
events: &'a [OwnedEventId], |
|
reason: Option<String>, |
|
) -> Result<(), Vec<&'a EventId>> { |
|
let matrix_room = self.matrix_room(); |
|
if matrix_room.state() != RoomState::Joined { |
|
return Ok(()); |
|
}; |
|
|
|
let events_clone = events.to_owned(); |
|
let matrix_room = matrix_room.clone(); |
|
let handle = spawn_tokio!(async move { |
|
let mut failed_redactions = Vec::new(); |
|
|
|
for (i, event_id) in events_clone.iter().enumerate() { |
|
match matrix_room.redact(event_id, reason.as_deref(), None).await { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!("Could not redact event with ID {event_id}: {error}"); |
|
failed_redactions.push(i); |
|
} |
|
} |
|
} |
|
|
|
failed_redactions |
|
}); |
|
|
|
let failed_redactions = handle.await.expect("task was not aborted"); |
|
let failed_redactions = failed_redactions |
|
.into_iter() |
|
.map(|i| &*events[i]) |
|
.collect::<Vec<_>>(); |
|
|
|
if failed_redactions.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed_redactions) |
|
} |
|
} |
|
|
|
/// Report the given events in this room. |
|
/// |
|
/// The events are a list of `(event_id, reason)` tuples. |
|
/// |
|
/// Returns `Ok(())` if all the reports are sent successfully, otherwise |
|
/// returns the list of event IDs that could not be reported. |
|
pub async fn report_events<'a>( |
|
&self, |
|
events: &'a [(OwnedEventId, Option<String>)], |
|
) -> Result<(), Vec<&'a EventId>> { |
|
let events_clone = events.to_owned(); |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { |
|
let futures = events_clone |
|
.into_iter() |
|
.map(|(event_id, reason)| matrix_room.report_content(event_id, None, reason)); |
|
futures_util::future::join_all(futures).await |
|
}); |
|
|
|
let mut failed = Vec::new(); |
|
for (index, result) in handle |
|
.await |
|
.expect("task was not aborted") |
|
.iter() |
|
.enumerate() |
|
{ |
|
match result { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!( |
|
"Could not report content with event ID {}: {error}", |
|
events[index].0, |
|
); |
|
failed.push(&*events[index].0); |
|
} |
|
} |
|
} |
|
|
|
if failed.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed) |
|
} |
|
} |
|
|
|
/// Invite the given users to this room. |
|
/// |
|
/// Returns `Ok(())` if all the invites are sent successfully, otherwise |
|
/// returns the list of users who could not be invited. |
|
pub async fn invite<'a>(&self, user_ids: &'a [OwnedUserId]) -> Result<(), Vec<&'a UserId>> { |
|
let matrix_room = self.matrix_room(); |
|
if matrix_room.state() != RoomState::Joined { |
|
error!("Can’t invite users, because this room isn’t a joined room"); |
|
return Ok(()); |
|
} |
|
|
|
let user_ids_clone = user_ids.to_owned(); |
|
let matrix_room = matrix_room.clone(); |
|
let handle = spawn_tokio!(async move { |
|
let invitations = user_ids_clone |
|
.iter() |
|
.map(|user_id| matrix_room.invite_user_by_id(user_id)); |
|
futures_util::future::join_all(invitations).await |
|
}); |
|
|
|
let mut failed_invites = Vec::new(); |
|
for (index, result) in handle |
|
.await |
|
.expect("task was not aborted") |
|
.iter() |
|
.enumerate() |
|
{ |
|
match result { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!("Could not invite user with ID {}: {error}", user_ids[index],); |
|
failed_invites.push(&*user_ids[index]); |
|
} |
|
} |
|
} |
|
|
|
if failed_invites.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed_invites) |
|
} |
|
} |
|
|
|
/// Kick the given users from this room. |
|
/// |
|
/// The users are a list of `(user_id, reason)` tuples. |
|
/// |
|
/// Returns `Ok(())` if all the kicks are sent successfully, otherwise |
|
/// returns the list of users who could not be kicked. |
|
pub async fn kick<'a>( |
|
&self, |
|
users: &'a [(OwnedUserId, Option<String>)], |
|
) -> Result<(), Vec<&'a UserId>> { |
|
let users_clone = users.to_owned(); |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { |
|
let futures = users_clone |
|
.iter() |
|
.map(|(user_id, reason)| matrix_room.kick_user(user_id, reason.as_deref())); |
|
futures_util::future::join_all(futures).await |
|
}); |
|
|
|
let mut failed_kicks = Vec::new(); |
|
for (index, result) in handle |
|
.await |
|
.expect("task was not aborted") |
|
.iter() |
|
.enumerate() |
|
{ |
|
match result { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!("Could not kick user with ID {}: {error}", users[index].0); |
|
failed_kicks.push(&*users[index].0); |
|
} |
|
} |
|
} |
|
|
|
if failed_kicks.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed_kicks) |
|
} |
|
} |
|
|
|
/// Ban the given users from this room. |
|
/// |
|
/// The users are a list of `(user_id, reason)` tuples. |
|
/// |
|
/// Returns `Ok(())` if all the bans are sent successfully, otherwise |
|
/// returns the list of users who could not be banned. |
|
pub async fn ban<'a>( |
|
&self, |
|
users: &'a [(OwnedUserId, Option<String>)], |
|
) -> Result<(), Vec<&'a UserId>> { |
|
let users_clone = users.to_owned(); |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { |
|
let futures = users_clone |
|
.iter() |
|
.map(|(user_id, reason)| matrix_room.ban_user(user_id, reason.as_deref())); |
|
futures_util::future::join_all(futures).await |
|
}); |
|
|
|
let mut failed_bans = Vec::new(); |
|
for (index, result) in handle |
|
.await |
|
.expect("task was not aborted") |
|
.iter() |
|
.enumerate() |
|
{ |
|
match result { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!("Could not ban user with ID {}: {error}", users[index].0); |
|
failed_bans.push(&*users[index].0); |
|
} |
|
} |
|
} |
|
|
|
if failed_bans.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed_bans) |
|
} |
|
} |
|
|
|
/// Unban the given users from this room. |
|
/// |
|
/// The users are a list of `(user_id, reason)` tuples. |
|
/// |
|
/// Returns `Ok(())` if all the unbans are sent successfully, otherwise |
|
/// returns the list of users who could not be unbanned. |
|
pub async fn unban<'a>( |
|
&self, |
|
users: &'a [(OwnedUserId, Option<String>)], |
|
) -> Result<(), Vec<&'a UserId>> { |
|
let users_clone = users.to_owned(); |
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { |
|
let futures = users_clone |
|
.iter() |
|
.map(|(user_id, reason)| matrix_room.unban_user(user_id, reason.as_deref())); |
|
futures_util::future::join_all(futures).await |
|
}); |
|
|
|
let mut failed_unbans = Vec::new(); |
|
for (index, result) in handle |
|
.await |
|
.expect("task was not aborted") |
|
.iter() |
|
.enumerate() |
|
{ |
|
match result { |
|
Ok(_) => {} |
|
Err(error) => { |
|
error!("Could not unban user with ID {}: {error}", users[index].0); |
|
failed_unbans.push(&*users[index].0); |
|
} |
|
} |
|
} |
|
|
|
if failed_unbans.is_empty() { |
|
Ok(()) |
|
} else { |
|
Err(failed_unbans) |
|
} |
|
} |
|
|
|
/// Enable encryption for this room. |
|
pub async fn enable_encryption(&self) -> Result<(), ()> { |
|
if self.is_encrypted() { |
|
// Nothing to do. |
|
return Ok(()); |
|
} |
|
|
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.enable_encryption().await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(_) => Ok(()), |
|
Err(error) => { |
|
error!("Could not enabled room encryption: {error}"); |
|
Err(()) |
|
} |
|
} |
|
} |
|
|
|
/// Forget a room that is left. |
|
pub async fn forget(&self) -> MatrixResult<()> { |
|
if self.category() != RoomCategory::Left { |
|
warn!("Cannot forget a room that is not left"); |
|
return Ok(()); |
|
} |
|
|
|
let matrix_room = self.matrix_room().clone(); |
|
let handle = spawn_tokio!(async move { matrix_room.forget().await }); |
|
|
|
match handle.await.expect("task was not aborted") { |
|
Ok(_) => { |
|
self.emit_by_name::<()>("room-forgotten", &[]); |
|
Ok(()) |
|
} |
|
Err(error) => { |
|
error!("Could not forget the room: {error}"); |
|
Err(error) |
|
} |
|
} |
|
} |
|
|
|
/// Handle room member name ambiguity changes. |
|
pub fn handle_ambiguity_changes<'a>(&self, changes: impl Iterator<Item = &'a AmbiguityChange>) { |
|
self.imp().handle_ambiguity_changes(changes); |
|
} |
|
|
|
/// Update the latest activity of the room with the given events. |
|
/// |
|
/// The events must be in reverse chronological order. |
|
fn update_latest_activity<'a>(&self, events: impl IntoIterator<Item = &'a Event>) { |
|
let mut latest_activity = self.latest_activity(); |
|
|
|
for event in events { |
|
if event.counts_as_unread() { |
|
latest_activity = latest_activity.max(event.origin_server_ts_u64()); |
|
break; |
|
} |
|
} |
|
|
|
self.imp().set_latest_activity(latest_activity); |
|
} |
|
|
|
/// Update the successor of this room. |
|
pub fn update_successor(&self) { |
|
self.imp().update_successor() |
|
} |
|
|
|
/// Connect to the signal emitted when the room was forgotten. |
|
pub fn connect_room_forgotten<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId { |
|
self.connect_closure( |
|
"room-forgotten", |
|
true, |
|
closure_local!(move |obj: Self| { |
|
f(&obj); |
|
}), |
|
) |
|
} |
|
} |
|
|
|
/// Supported values for the history visibility. |
|
#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] |
|
#[enum_type(name = "HistoryVisibilityValue")] |
|
pub enum HistoryVisibilityValue { |
|
/// Anyone can read. |
|
WorldReadable, |
|
/// Members, since this was selected. |
|
#[default] |
|
Shared, |
|
/// Members, since they were invited. |
|
Invited, |
|
/// Members, since they joined. |
|
Joined, |
|
/// Unsupported value. |
|
Unsupported, |
|
} |
|
|
|
impl From<HistoryVisibility> for HistoryVisibilityValue { |
|
fn from(value: HistoryVisibility) -> Self { |
|
match value { |
|
HistoryVisibility::Invited => Self::Invited, |
|
HistoryVisibility::Joined => Self::Joined, |
|
HistoryVisibility::Shared => Self::Shared, |
|
HistoryVisibility::WorldReadable => Self::WorldReadable, |
|
_ => Self::Unsupported, |
|
} |
|
} |
|
} |
|
|
|
impl From<HistoryVisibilityValue> for HistoryVisibility { |
|
fn from(value: HistoryVisibilityValue) -> Self { |
|
match value { |
|
HistoryVisibilityValue::Invited => Self::Invited, |
|
HistoryVisibilityValue::Joined => Self::Joined, |
|
HistoryVisibilityValue::Shared => Self::Shared, |
|
HistoryVisibilityValue::WorldReadable => Self::WorldReadable, |
|
HistoryVisibilityValue::Unsupported => unimplemented!(), |
|
} |
|
} |
|
} |
|
|
|
/// The position of the receipt to send. |
|
#[derive(Debug, Clone)] |
|
pub enum ReceiptPosition { |
|
/// We are at the end of the timeline (bottom of the view). |
|
End, |
|
/// We are at the event with the given ID. |
|
Event(OwnedEventId), |
|
}
|
|
|