room-history: Remove banner for tombstoned rooms

Instead we offer to join or view the new room instead of showing the
composer.

This also makes sure that the state of the Join/View button is updated
depending on the joining state of the successor.
This commit is contained in:
Kévin Commaille 2025-05-11 15:39:45 +02:00
parent 9f426e2c6d
commit 893f795228
No known key found for this signature in database
GPG Key ID: F26F4BE20A08255B
5 changed files with 269 additions and 141 deletions

View File

@ -1469,8 +1469,8 @@ impl Room {
}
/// The ID of the successor of this Room, if this room was upgraded.
pub(crate) fn successor_id(&self) -> Option<&RoomId> {
self.imp().successor_id.get().map(std::ops::Deref::deref)
pub(crate) fn successor_id(&self) -> Option<&OwnedRoomId> {
self.imp().successor_id.get()
}
/// The `matrix.to` URI representation for this room.

View File

@ -37,10 +37,10 @@ use self::{
};
use super::message_row::MessageContent;
use crate::{
components::{CustomEntry, LabelWithWidgets},
components::{CustomEntry, LabelWithWidgets, LoadingButton},
gettext_f,
prelude::*,
session::model::{Event, Member, Room, Timeline},
session::model::{Event, Member, Room, RoomListRoomInfo, Timeline},
spawn, spawn_tokio, toast,
utils::{
media::{
@ -49,11 +49,24 @@ use crate::{
},
Location, LocationError, TemplateCallbacks, TokioDrop,
},
Application, Window,
};
/// A map of composer state per-session and per-room.
type ComposerStatesMap = HashMap<Option<String>, HashMap<Option<OwnedRoomId>, ComposerState>>;
/// The available stack pages of the [`MessageToolbar`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::AsRefStr, strum::EnumString)]
#[strum(serialize_all = "kebab-case")]
enum MessageToolbarPage {
/// The composer and other buttons to send messages.
Composer,
/// The user is not allowed to send messages in the room.
NoPermission,
/// The room was tombstoned.
Tombstoned,
}
mod imp {
use std::{
cell::{Cell, RefCell},
@ -63,7 +76,6 @@ mod imp {
use glib::subclass::InitializingObject;
use super::*;
use crate::Application;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
@ -81,9 +93,15 @@ mod imp {
related_event_header: TemplateChild<LabelWithWidgets>,
#[template_child]
related_event_content: TemplateChild<MessageContent>,
#[template_child]
tombstoned_label: TemplateChild<gtk::Label>,
#[template_child]
tombstoned_button: TemplateChild<LoadingButton>,
/// The timeline used to send messages.
#[property(get, set = Self::set_timeline, explicit_notify, nullable)]
timeline: glib::WeakRef<Timeline>,
successor_room_list_info: RoomListRoomInfo,
room_handlers: RefCell<Vec<glib::SignalHandlerId>>,
send_message_permission_handler: RefCell<Option<glib::SignalHandlerId>>,
/// Whether outgoing messages should be interpreted as markdown.
#[property(get, set)]
@ -150,30 +168,53 @@ mod imp {
// Location.
let location = Location::new();
obj.action_set_enabled("message-toolbar.send-location", location.is_available());
// Listen to changes in the room list for the successor.
self.successor_room_list_info
.connect_is_joining_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_tombstoned_page();
}
));
self.successor_room_list_info
.connect_local_room_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_tombstoned_page();
}
));
}
fn dispose(&self) {
self.completion.unparent();
if let Some(timeline) = self.timeline.upgrade() {
if let Some(handler) = self.send_message_permission_handler.take() {
timeline.room().permissions().disconnect(handler);
}
}
self.disconnect_signals();
}
}
impl WidgetImpl for MessageToolbar {
fn grab_focus(&self) -> bool {
if self
let Some(visible_page) = self
.main_stack
.visible_child_name()
.is_none_or(|name| name == "disabled")
{
.and_then(|name| MessageToolbarPage::try_from(name.as_str()).ok())
else {
return false;
}
};
self.message_entry.grab_focus()
match visible_page {
MessageToolbarPage::Composer => self.message_entry.grab_focus(),
MessageToolbarPage::NoPermission => false,
MessageToolbarPage::Tombstoned => {
if self.tombstoned_button.is_visible() {
self.tombstoned_button.grab_focus()
} else {
false
}
}
}
}
}
@ -189,13 +230,39 @@ mod imp {
}
let obj = self.obj();
if let Some(timeline) = &old_timeline {
if let Some(handler) = self.send_message_permission_handler.take() {
timeline.room().permissions().disconnect(handler);
}
}
self.disconnect_signals();
if let Some(timeline) = timeline {
let room = timeline.room();
let is_tombstoned_handler = room.connect_is_tombstoned_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_visible_page();
}
));
let successor_id_handler = room.connect_successor_id_string_notify(clone!(
#[weak(rename_to= imp)]
self,
move |_| {
imp.update_successor_identifier();
imp.update_tombstoned_page();
}
));
let successor_handler = room.connect_successor_notify(clone!(
#[weak(rename_to= imp)]
self,
move |_| {
imp.update_tombstoned_page();
}
));
self.room_handlers.replace(vec![
is_tombstoned_handler,
successor_id_handler,
successor_handler,
]);
let send_message_permission_handler = timeline
.room()
.permissions()
@ -203,41 +270,105 @@ mod imp {
#[weak(rename_to = imp)]
self,
move |_| {
imp.send_message_permission_updated();
imp.update_visible_page();
}
));
self.send_message_permission_handler
.replace(Some(send_message_permission_handler));
if let Some(session) = room.session() {
self.successor_room_list_info
.set_room_list(session.room_list());
}
}
self.completion.set_room(timeline.map(Timeline::room));
self.timeline.set(timeline);
self.send_message_permission_updated();
self.update_successor_identifier();
self.update_tombstoned_page();
self.update_visible_page();
obj.notify_timeline();
self.update_current_composer_state(old_timeline);
}
/// The stack page that should be presented given the current state.
fn visible_page(&self) -> MessageToolbarPage {
let Some(room) = self.timeline.upgrade().map(|timeline| timeline.room()) else {
return MessageToolbarPage::NoPermission;
};
if room.is_tombstoned() {
MessageToolbarPage::Tombstoned
} else if room.permissions().can_send_message() {
MessageToolbarPage::Composer
} else {
MessageToolbarPage::NoPermission
}
}
/// Whether the user can compose a message.
///
/// It depends on whether our own user has the permission to send a
/// message in the current room.
pub(super) fn can_compose_message(&self) -> bool {
self.timeline
.upgrade()
.is_some_and(|timeline| timeline.room().permissions().can_send_message())
self.visible_page() == MessageToolbarPage::Composer
}
/// Handle an update of the permission to send a message in the current
/// Update the visible stack page.
fn update_visible_page(&self) {
self.main_stack
.set_visible_child_name(self.visible_page().as_ref());
}
/// Update the identifier to watch for the successor of the current
/// room.
fn send_message_permission_updated(&self) {
let page = if self.can_compose_message() {
"enabled"
} else {
"disabled"
fn update_successor_identifier(&self) {
let successor_id = self
.timeline
.upgrade()
.and_then(|timeline| timeline.room().successor_id().cloned());
self.successor_room_list_info
.set_identifiers(successor_id.into_iter().map(Into::into).collect());
}
/// Update the tombstoned stack page.
fn update_tombstoned_page(&self) {
let Some(room) = self.timeline.upgrade().map(|timeline| timeline.room()) else {
return;
};
self.main_stack.set_visible_child_name(page);
// A "real" successor must have the current room as a predecessor. We still want
// to show the "View" button if it is only the room that matches the successor
// ID.
let has_successor_room =
room.successor().is_some() || self.successor_room_list_info.local_room().is_some();
let has_successor_id = room.successor_id().is_some();
let has_successor = has_successor_room || has_successor_id;
// Update description.
let description = if has_successor {
gettext("The conversation continues in a new room")
} else {
gettext("The conversation has ended")
};
self.tombstoned_label.set_label(&description);
// Update button.
if has_successor {
let label = if has_successor_room {
// Translators: This is a verb, as in 'View Room'.
gettext("View")
} else {
gettext("Join")
};
self.tombstoned_button.set_content_label(label);
let is_joining_successor = self.successor_room_list_info.is_joining();
self.tombstoned_button.set_is_loading(is_joining_successor);
}
self.tombstoned_button.set_visible(has_successor);
}
/// Whether the buffer of the composer is empty.
@ -1044,6 +1175,62 @@ mod imp {
room.send_typing_notification(typing);
}
/// Join or view the successor of the room, if possible.
#[template_callback]
async fn join_or_view_successor(&self) {
let Some(room) = self.timeline.upgrade().map(|timeline| timeline.room()) else {
return;
};
let Some(session) = room.session() else {
return;
};
if !room.is_tombstoned() {
return;
}
let obj = self.obj();
if let Some(successor) = room
.successor()
.or_else(|| self.successor_room_list_info.local_room())
{
let Some(window) = obj.root().and_downcast::<Window>() else {
return;
};
window.session_view().select_room(successor);
} else if let Some(successor_id) = room.successor_id().cloned() {
let via = successor_id
.server_name()
.map(ToOwned::to_owned)
.into_iter()
.collect();
if let Err(error) = session
.room_list()
.join_by_id_or_alias(successor_id.into(), via)
.await
{
toast!(obj, error);
}
}
}
/// Disconnect the signal handlers of this toolbar.
fn disconnect_signals(&self) {
if let Some(timeline) = self.timeline.upgrade() {
let room = timeline.room();
for handler in self.room_handlers.take() {
room.disconnect(handler);
}
if let Some(handler) = self.send_message_permission_handler.take() {
room.permissions().disconnect(handler);
}
}
}
}
}

View File

@ -20,7 +20,7 @@
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">enabled</property>
<property name="name">composer</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
@ -169,7 +169,7 @@
</child>
<child>
<object class="GtkStackPage">
<property name="name">disabled</property>
<property name="name">no-permission</property>
<property name="child">
<object class="AdwClamp">
<property name="hexpand">True</property>
@ -193,6 +193,50 @@
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">tombstoned</property>
<property name="child">
<object class="AdwClamp">
<property name="hexpand">True</property>
<style>
<class name="composer-replacement"/>
</style>
<property name="child">
<object class="AdwWrapBox">
<property name="child-spacing">12</property>
<property name="line-spacing">12</property>
<property name="justify">fill</property>
<property name="justify-last-line">True</property>
<property name="halign">center</property>
<child>
<object class="GtkLabel" id="tombstoned_label">
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="justify">center</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
<child>
<object class="LoadingButton" id="tombstoned_button">
<property name="halign">center</property>
<signal name="clicked" handler="join_or_view_successor" swapped="yes"/>
<style>
<class name="text-button"/>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</template>

View File

@ -47,7 +47,6 @@ use crate::{
},
spawn, toast,
utils::{BoundObject, GroupingListGroup, GroupingListModel, LoadingState, TemplateCallbacks},
Window,
};
/// The time to wait before considering that scrolling has ended.
@ -96,8 +95,6 @@ mod imp {
#[template_child]
stack: TemplateChild<gtk::Stack>,
#[template_child]
tombstoned_banner: TemplateChild<adw::Banner>,
#[template_child]
drag_overlay: TemplateChild<DragOverlay>,
/// The context menu for rows presenting an [`Event`].
event_context_menu: OnceCell<EventActionsContextMenu>,
@ -123,7 +120,7 @@ mod imp {
grouping_model: OnceCell<GroupingListModel>,
scroll_timeout: RefCell<Option<glib::SourceId>>,
read_timeout: RefCell<Option<glib::SourceId>>,
room_handlers: RefCell<Vec<glib::SignalHandlerId>>,
room_handler: RefCell<Option<glib::SignalHandlerId>>,
can_invite_handler: RefCell<Option<glib::SignalHandlerId>>,
membership_handler: RefCell<Option<glib::SignalHandlerId>>,
join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
@ -391,7 +388,7 @@ mod imp {
/// Disconnect all the signals.
fn disconnect_all(&self) {
if let Some(room) = self.room() {
for handler in self.room_handlers.take() {
if let Some(handler) = self.room_handler.take() {
room.disconnect(handler);
}
@ -466,34 +463,8 @@ mod imp {
imp.update_invite_action();
}
));
let tombstoned_handler = room.connect_is_tombstoned_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_tombstoned_banner();
}
));
let successor_id_handler = room.connect_successor_id_string_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_tombstoned_banner();
}
));
let successor_handler = room.connect_successor_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_tombstoned_banner();
}
));
self.room_handlers.replace(vec![
is_direct_handler,
tombstoned_handler,
successor_id_handler,
successor_handler,
]);
self.room_handler.replace(Some(is_direct_handler));
let empty_handler = timeline.connect_is_empty_notify(clone!(
#[weak(rename_to = imp)]
@ -533,7 +504,6 @@ mod imp {
self.update_view();
self.load_more_events_if_needed();
self.update_room_menu();
self.update_tombstoned_banner();
self.update_invite_action();
self.obj().notify_timeline();
@ -931,36 +901,6 @@ mod imp {
None
}
/// Update the tombstoned banner according to the state of the current
/// room.
fn update_tombstoned_banner(&self) {
let banner = &self.tombstoned_banner;
let Some(room) = self.room() else {
banner.set_revealed(false);
return;
};
if !room.is_tombstoned() {
banner.set_revealed(false);
return;
}
if room.successor().is_some() {
banner.set_title(&gettext("There is a newer version of this room"));
// Translators: This is a verb, as in 'View Room'.
banner.set_button_label(Some(&gettext("View")));
} else if room.successor_id().is_some() {
banner.set_title(&gettext("There is a newer version of this room"));
banner.set_button_label(Some(&gettext("Join")));
} else {
banner.set_title(&gettext("This room was closed"));
banner.set_button_label(None);
}
banner.set_revealed(true);
}
/// Leave the room.
async fn leave(&self) {
let Some(room) = self.room() else {
@ -1042,44 +982,6 @@ mod imp {
.action_set_enabled("room-history.invite-members", can_invite);
}
/// Join or view the successor of the room, if possible.
#[template_callback]
async fn join_or_view_successor(&self) {
let Some(room) = self.room() else {
return;
};
let Some(session) = room.session() else {
return;
};
if !room.is_joined() || !room.is_tombstoned() {
return;
}
let obj = self.obj();
if let Some(successor) = room.successor() {
let Some(window) = obj.root().and_downcast::<Window>() else {
return;
};
window.session_view().select_room(successor);
} else if let Some(successor_id) = room.successor_id().map(ToOwned::to_owned) {
let via = successor_id
.server_name()
.map(ToOwned::to_owned)
.into_iter()
.collect();
if let Err(error) = session
.room_list()
.join_by_id_or_alias(successor_id.into(), via)
.await
{
toast!(obj, error);
}
}
}
/// The context menu for rows presenting an [`Event`].
pub(super) fn event_context_menu(&self) -> &EventActionsContextMenu {
self.event_context_menu.get_or_init(Default::default)

View File

@ -157,11 +157,6 @@
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwBanner" id="tombstoned_banner">
<signal name="button-clicked" handler="join_or_view_successor" swapped="yes"/>
</object>
</child>
<child>
<object class="ContentVerificationInfoBar" id="verification_info_bar">
<binding name="verification">