From aff322a42a751b396c742ea597f32b9120aab776 Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 24 Feb 2026 22:25:21 +0900 Subject: [PATCH 1/8] Add new timeline event handling for room tombstoned event --- commet/lib/client/matrix/matrix_room.dart | 3 ++ .../matrix_timeline_event_room_tombstone.dart | 49 +++++++++++++++++++ .../timeline_event_room_tombstone.dart | 5 ++ .../timeline_events/timeline_view_entry.dart | 3 ++ 4 files changed, 60 insertions(+) create mode 100644 commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart create mode 100644 commet/lib/client/timeline_events/timeline_event_room_tombstone.dart diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index 0b1c3028e..10546a505 100644 --- a/commet/lib/client/matrix/matrix_room.dart +++ b/commet/lib/client/matrix/matrix_room.dart @@ -33,6 +33,7 @@ import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_membe import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_message.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_pinned_messages.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_redaction.dart'; +import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_sticker.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event_unknown.dart'; import 'package:commet/client/member.dart'; @@ -531,6 +532,8 @@ class MatrixRoom extends Room { MatrixTimelineEventCall(event, client: c), matrix.EventTypes.RoomPinnedEvents => MatrixTimelineEventPinnedMessages(event, client: c), + matrix.EventTypes.RoomTombstone => + MatrixTimelineEventRoomTombstone(event, client: c), "chat.commet.calendar_events" => MatrixTimelineEventEditCalendar(event, client: c), _ => null diff --git a/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart b/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart new file mode 100644 index 000000000..129c6a181 --- /dev/null +++ b/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart @@ -0,0 +1,49 @@ +import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/client/timeline_events/timeline_event_generic.dart'; +import 'package:commet/client/timeline_events/timeline_event_room_tombstone.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class MatrixTimelineEventRoomTombstone extends MatrixTimelineEvent + implements TimelineEventRoomTombstone, TimelineEventGeneric { + MatrixTimelineEventRoomTombstone(super.event, {required super.client}); + + String messageRoomUpgraded(String sender) => Intl.message( + "$sender upgraded this room", + name: "messageRoomUpgraded", + args: [sender], + desc: "Shown when a room was replaced by another room", + ); + + String get fallbackMessage => Intl.message( + "This room has been upgraded or replaced", + name: "messageRoomReplaced", + desc: "Fallback tombstone text when no sender/body available", + ); + + @override + String? get replacementRoomId => event.content["replacement_room"] as String?; + + @override + String getBody({Timeline? timeline}) { + final body = event.content["body"] as String?; + if (body != null && body.trim().isNotEmpty) return body; + + final sender = timeline != null + ? timeline.room.getMemberOrFallback(event.senderId).displayName + : event.senderId.split(":").first.replaceFirst("@", ""); + + if (sender != null && sender.isNotEmpty) { + return messageRoomUpgraded(sender); + } + + return fallbackMessage; + } + + @override + IconData? get icon => Icons.upgrade_rounded; + + @override + bool get showSenderAvatar => false; +} diff --git a/commet/lib/client/timeline_events/timeline_event_room_tombstone.dart b/commet/lib/client/timeline_events/timeline_event_room_tombstone.dart new file mode 100644 index 000000000..57e63589b --- /dev/null +++ b/commet/lib/client/timeline_events/timeline_event_room_tombstone.dart @@ -0,0 +1,5 @@ +import 'package:commet/client/timeline_events/timeline_event.dart'; + +abstract class TimelineEventRoomTombstone extends TimelineEvent { + String? get replacementRoomId; +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart index 19b527f02..dc49901b0 100644 --- a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart +++ b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart @@ -6,6 +6,7 @@ import 'package:commet/client/timeline_events/timeline_event_emote.dart'; import 'package:commet/client/timeline_events/timeline_event_encrypted.dart'; import 'package:commet/client/timeline_events/timeline_event_generic.dart'; import 'package:commet/client/timeline_events/timeline_event_message.dart'; +import 'package:commet/client/timeline_events/timeline_event_room_tombstone.dart'; import 'package:commet/client/timeline_events/timeline_event_sticker.dart'; import 'package:commet/config/layout_config.dart'; import 'package:commet/debug/log.dart'; @@ -130,6 +131,8 @@ class TimelineViewEntryState extends State event is TimelineEventSticker || event is TimelineEventEncrypted) { return TimelineEventWidgetDisplayType.message; + } else if (event is TimelineEventRoomTombstone) { + return TimelineEventWidgetDisplayType.generic; } else if (event is TimelineEventGeneric) { return TimelineEventWidgetDisplayType.generic; } else if (event.status == TimelineEventStatus.error) { From 87ef98e676586131a26132fd577d6a89bafbfaff Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 24 Feb 2026 23:38:07 +0900 Subject: [PATCH 2/8] Add properties and methods for handling tombstone room --- commet/lib/client/matrix/matrix_room.dart | 17 ++++++++++++++++- commet/lib/client/room.dart | 9 +++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index 10546a505..3379b35b4 100644 --- a/commet/lib/client/matrix/matrix_room.dart +++ b/commet/lib/client/matrix/matrix_room.dart @@ -95,6 +95,20 @@ class MatrixRoom extends Room { @override bool get isE2EE => _matrixRoom.encrypted; + @override + bool get isTombstoned => + matrixRoom.getState(matrix.EventTypes.RoomTombstone) != null; + + @override + String? get tombstoneReplacementRoomId => matrixRoom + .getState(matrix.EventTypes.RoomTombstone) + ?.content["replacement_room"] as String?; + + @override + String? get tombstoneBody => + matrixRoom.getState(matrix.EventTypes.RoomTombstone)?.content["body"] + as String?; + @override int get highlightedNotificationCount => _matrixRoom.highlightCount; @@ -721,7 +735,8 @@ class MatrixRoom extends Room { _displayName = _matrixRoom.getLocalizedDisplayname(); if (event.state.type == "m.room.name" || event.state.type == "m.room.avatar" || - event.state.type == "m.room.topic") { + event.state.type == "m.room.topic" || + event.state.type == matrix.EventTypes.RoomTombstone) { _onUpdate.add(null); } } diff --git a/commet/lib/client/room.dart b/commet/lib/client/room.dart index 5a3173a7b..0435b8ce7 100644 --- a/commet/lib/client/room.dart +++ b/commet/lib/client/room.dart @@ -91,6 +91,15 @@ abstract class Room { /// Returns true if the room is secured by end to end encryption bool get isE2EE; + /// Returns true if the room has a tombstone state + bool get isTombstoned; + + /// Returns the replacement room ID when tombstoned + String? get tombstoneReplacementRoomId; + + /// Returns the tombstone body message when available + String? get tombstoneBody; + bool get isSpecialRoomType; IconData get icon { From dd6ea4fcd6c3d4ab9fa943116a62d814cf5b2f7c Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Wed, 25 Feb 2026 00:10:34 +0900 Subject: [PATCH 3/8] Display a button on chat input area that moves user to new room if the room is tombstoned. --- .../matrix_background_room.dart | 14 +++++ commet/lib/ui/organisms/chat/chat_view.dart | 51 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/commet/lib/client/matrix_background/matrix_background_room.dart b/commet/lib/client/matrix_background/matrix_background_room.dart index 67513dafd..6fc5b6340 100644 --- a/commet/lib/client/matrix_background/matrix_background_room.dart +++ b/commet/lib/client/matrix_background/matrix_background_room.dart @@ -215,6 +215,20 @@ class MatrixBackgroundRoom implements Room { bool get isE2EE => _stateEvents.any((e) => e.type == matrix.EventTypes.Encryption); + @override + bool get isTombstoned => _stateEvents + .any((event) => event.type == matrix.EventTypes.RoomTombstone); + + matrix.BasicEvent? get _tombstoneState => _stateEvents.firstWhereOrNull( + (event) => event.type == matrix.EventTypes.RoomTombstone); + + @override + String? get tombstoneReplacementRoomId => + _tombstoneState?.content["replacement_room"] as String?; + + @override + String? get tombstoneBody => _tombstoneState?.content["body"] as String?; + @override bool get isMembersListComplete => throw UnimplementedError(); diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 9de511cd7..367f40887 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -32,6 +32,15 @@ class ChatView extends StatelessWidget { name: "cantSentMessagePrompt", desc: "Text that explains the user cannot send a message in this room"); + String get tombstoneRoomReplacedMessage => + Intl.message("This room has been replaced", + name: "tombstoneRoomReplacedMessage", + desc: "Text that explains a room was replaced by another room"); + + String get tombstoneEnterNewRoom => Intl.message("Enter new room", + name: "tombstoneEnterNewRoom", + desc: "Button label for navigating to the replacement room"); + String? get relatedEventSenderName => state.interactingEvent == null ? null : state.room @@ -50,7 +59,7 @@ class ChatView extends StatelessWidget { fit: StackFit.expand, children: [timeline(), const ParticlePlayer()], )), - input(), + input(context), ]); } @@ -89,7 +98,7 @@ class ChatView extends StatelessWidget { NotificationManager.clearNotifications(room); } - Widget input() { + Widget input(BuildContext context) { String? interactingEventBody = state.interactingEvent?.plainTextBody; if (state.interactingEvent case TimelineEventMessage m) { @@ -98,6 +107,44 @@ class ChatView extends StatelessWidget { } } + if (state.room.isTombstoned) { + final replacementRoomId = state.room.tombstoneReplacementRoomId; + final body = state.room.tombstoneBody?.trim(); + final displayMessage = + body != null && body.isNotEmpty ? body : tombstoneRoomReplacedMessage; + + return ClipRRect( + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + color: Theme.of(context).colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayMessage, + style: Theme.of(context).textTheme.bodySmall, + ), + if (replacementRoomId != null && replacementRoomId.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: TextButton.icon( + onPressed: () { + EventBus.openRoom.add( + (replacementRoomId, state.room.client.identifier), + ); + }, + icon: const Icon(Icons.arrow_forward), + label: Text(tombstoneEnterNewRoom), + ), + ), + ], + ), + ), + ); + } + return ClipRRect( child: MessageInput( client: state.room.client, From 36d27e7757ae8a0ec9ffd615953da4481e751bb2 Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Wed, 25 Feb 2026 00:53:29 +0900 Subject: [PATCH 4/8] Update enter new room button action: Enter new room then leave the tombstoned room --- commet/lib/ui/organisms/chat/chat_view.dart | 24 +++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 367f40887..529c62d1a 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -41,6 +41,24 @@ class ChatView extends StatelessWidget { name: "tombstoneEnterNewRoom", desc: "Button label for navigating to the replacement room"); + Future openReplacementRoomAndLeave(String replacementRoomId) async { + final client = state.room.client; + Room? targetRoom = client.getRoom(replacementRoomId); + + targetRoom ??= client.getRoomByAlias(replacementRoomId); + + if (targetRoom == null) { + try { + targetRoom = await client.joinRoom(replacementRoomId); + } catch (_) { + return; + } + } + + EventBus.openRoom.add((targetRoom.identifier, client.identifier)); + await client.leaveRoom(state.room); + } + String? get relatedEventSenderName => state.interactingEvent == null ? null : state.room @@ -130,10 +148,8 @@ class ChatView extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 8), child: TextButton.icon( - onPressed: () { - EventBus.openRoom.add( - (replacementRoomId, state.room.client.identifier), - ); + onPressed: () async { + await openReplacementRoomAndLeave(replacementRoomId); }, icon: const Icon(Icons.arrow_forward), label: Text(tombstoneEnterNewRoom), From 66c80be847e7d853c0c215bfb61c412ed2982d04 Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 3 Mar 2026 20:39:11 +0900 Subject: [PATCH 5/8] Update getter name for message key messageRoomReplaced --- .../timeline_events/matrix_timeline_event_room_tombstone.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart b/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart index 129c6a181..7b017dd81 100644 --- a/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart +++ b/commet/lib/client/matrix/timeline_events/matrix_timeline_event_room_tombstone.dart @@ -16,7 +16,7 @@ class MatrixTimelineEventRoomTombstone extends MatrixTimelineEvent desc: "Shown when a room was replaced by another room", ); - String get fallbackMessage => Intl.message( + String get messageRoomReplaced => Intl.message( "This room has been upgraded or replaced", name: "messageRoomReplaced", desc: "Fallback tombstone text when no sender/body available", @@ -38,7 +38,7 @@ class MatrixTimelineEventRoomTombstone extends MatrixTimelineEvent return messageRoomUpgraded(sender); } - return fallbackMessage; + return messageRoomReplaced; } @override From 362f8e834819584fcd24a8123581c4219320959b Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 3 Mar 2026 20:56:46 +0900 Subject: [PATCH 6/8] Update exception handling when joining replacement room --- commet/lib/ui/organisms/chat/chat_view.dart | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 529c62d1a..5abffd8c3 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -13,6 +13,7 @@ import 'package:commet/utils/autofill_utils.dart'; import 'package:commet/utils/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:commet/utils/error_utils.dart'; class ChatView extends StatelessWidget { const ChatView(this.state, {super.key}); @@ -41,22 +42,19 @@ class ChatView extends StatelessWidget { name: "tombstoneEnterNewRoom", desc: "Button label for navigating to the replacement room"); - Future openReplacementRoomAndLeave(String replacementRoomId) async { + Future openReplacementRoomAndLeave( + BuildContext context, String replacementRoomId) async { final client = state.room.client; Room? targetRoom = client.getRoom(replacementRoomId); targetRoom ??= client.getRoomByAlias(replacementRoomId); - - if (targetRoom == null) { - try { + ErrorUtils.tryRun(context, () async { + if (targetRoom == null) { targetRoom = await client.joinRoom(replacementRoomId); - } catch (_) { - return; } - } - - EventBus.openRoom.add((targetRoom.identifier, client.identifier)); - await client.leaveRoom(state.room); + EventBus.openRoom.add((targetRoom!.identifier, client.identifier)); + await client.leaveRoom(state.room); + }); } String? get relatedEventSenderName => state.interactingEvent == null @@ -149,7 +147,8 @@ class ChatView extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: TextButton.icon( onPressed: () async { - await openReplacementRoomAndLeave(replacementRoomId); + await openReplacementRoomAndLeave( + context, replacementRoomId); }, icon: const Icon(Icons.arrow_forward), label: Text(tombstoneEnterNewRoom), From 13922d869558bdcb8431dabd442cf26aa59cbfee Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 3 Mar 2026 21:10:32 +0900 Subject: [PATCH 7/8] Extract buildTombstoneInput from input method --- commet/lib/ui/organisms/chat/chat_view.dart | 79 +++++++++++---------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 5abffd8c3..97f2e98bf 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -69,13 +69,16 @@ class ChatView extends StatelessWidget { @override Widget build(BuildContext context) { + final inputWidget = + state.room.isTombstoned ? buildTombstoneInput(context) : input(context); + return Column(children: [ Expanded( child: Stack( fit: StackFit.expand, children: [timeline(), const ParticlePlayer()], )), - input(context), + inputWidget, ]); } @@ -114,6 +117,43 @@ class ChatView extends StatelessWidget { NotificationManager.clearNotifications(room); } + Widget buildTombstoneInput(BuildContext context) { + final replacementRoomId = state.room.tombstoneReplacementRoomId; + final body = state.room.tombstoneBody?.trim(); + final displayMessage = + body != null && body.isNotEmpty ? body : tombstoneRoomReplacedMessage; + + return ClipRRect( + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + color: Theme.of(context).colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayMessage, + style: Theme.of(context).textTheme.bodySmall, + ), + if (replacementRoomId != null && replacementRoomId.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: TextButton.icon( + onPressed: () async { + await openReplacementRoomAndLeave( + context, replacementRoomId); + }, + icon: const Icon(Icons.arrow_forward), + label: Text(tombstoneEnterNewRoom), + ), + ), + ], + ), + ), + ); + } + Widget input(BuildContext context) { String? interactingEventBody = state.interactingEvent?.plainTextBody; @@ -123,43 +163,6 @@ class ChatView extends StatelessWidget { } } - if (state.room.isTombstoned) { - final replacementRoomId = state.room.tombstoneReplacementRoomId; - final body = state.room.tombstoneBody?.trim(); - final displayMessage = - body != null && body.isNotEmpty ? body : tombstoneRoomReplacedMessage; - - return ClipRRect( - child: Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - color: Theme.of(context).colorScheme.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayMessage, - style: Theme.of(context).textTheme.bodySmall, - ), - if (replacementRoomId != null && replacementRoomId.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: TextButton.icon( - onPressed: () async { - await openReplacementRoomAndLeave( - context, replacementRoomId); - }, - icon: const Icon(Icons.arrow_forward), - label: Text(tombstoneEnterNewRoom), - ), - ), - ], - ), - ), - ); - } - return ClipRRect( child: MessageInput( client: state.room.client, From 68c592182932ebfd95e713d96beb15af78b68b64 Mon Sep 17 00:00:00 2001 From: Youngbin Han Date: Tue, 3 Mar 2026 21:28:00 +0900 Subject: [PATCH 8/8] Handle via query param from room address when opening the replacement room. - Only pass room id for getRoom and getRoomByAlias - Pass address including via param for joinRoom --- commet/lib/ui/organisms/chat/chat_view.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 97f2e98bf..3ea84abfe 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -1,5 +1,6 @@ import 'package:commet/client/components/account_switch_prefix/account_switch_prefix.dart'; import 'package:commet/client/components/push_notification/notification_manager.dart'; +import 'package:commet/client/matrix/matrix_client.dart'; import 'package:commet/client/room.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; import 'package:commet/client/timeline_events/timeline_event_message.dart'; @@ -45,12 +46,22 @@ class ChatView extends StatelessWidget { Future openReplacementRoomAndLeave( BuildContext context, String replacementRoomId) async { final client = state.room.client; - Room? targetRoom = client.getRoom(replacementRoomId); + final joinAddress = replacementRoomId; + var lookupAddress = replacementRoomId; - targetRoom ??= client.getRoomByAlias(replacementRoomId); + if (client is MatrixClient) { + final info = client.parseAddressToIdAndVia(replacementRoomId); + if (info != null) { + lookupAddress = info.$1; + } + } + + Room? targetRoom = client.getRoom(lookupAddress); + + targetRoom ??= client.getRoomByAlias(lookupAddress); ErrorUtils.tryRun(context, () async { if (targetRoom == null) { - targetRoom = await client.joinRoom(replacementRoomId); + targetRoom = await client.joinRoom(joinAddress); } EventBus.openRoom.add((targetRoom!.identifier, client.identifier)); await client.leaveRoom(state.room);