diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index 0b1c3028e..3379b35b4 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'; @@ -94,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; @@ -531,6 +546,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 @@ -718,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/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/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/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 { 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 7d7abe822..b83320250 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'; @@ -134,6 +135,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) { diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 9de511cd7..529c62d1a 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -32,6 +32,33 @@ 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"); + + 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 @@ -50,7 +77,7 @@ class ChatView extends StatelessWidget { fit: StackFit.expand, children: [timeline(), const ParticlePlayer()], )), - input(), + input(context), ]); } @@ -89,7 +116,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 +125,42 @@ 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(replacementRoomId); + }, + icon: const Icon(Icons.arrow_forward), + label: Text(tombstoneEnterNewRoom), + ), + ), + ], + ), + ), + ); + } + return ClipRRect( child: MessageInput( client: state.room.client,