From 029efe7c0a17a13d15e2d5b355829eb8bad4b24b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 3 Nov 2026 16:53:28 -0500 Subject: [PATCH] add tombstone support Fixes #147 --- lib/controllers/tombstone.dart | 22 ++++ lib/models/content/content.dart | 3 +- lib/models/content/tombstone.dart | 17 +++ lib/widgets/renderers/event.dart | 44 +++++++- lib/widgets/room_chat/room_chat.dart | 153 +++++++++++++++------------ lib/widgets/tombstone_banner.dart | 56 ++++++++++ 6 files changed, 225 insertions(+), 70 deletions(-) create mode 100644 lib/controllers/tombstone.dart create mode 100644 lib/models/content/tombstone.dart create mode 100644 lib/widgets/tombstone_banner.dart diff --git a/lib/controllers/tombstone.dart b/lib/controllers/tombstone.dart new file mode 100644 index 0000000..92c275b --- /dev/null +++ b/lib/controllers/tombstone.dart @@ -0,0 +1,22 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/event.dart"; + +class TombstoneController(final String roomId) extends Notifier { + @override + Event? build() => ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[roomId]; + return switch (room?.state[EventType.tombstone.type]?[""]) { + final rowId? => room?.events[rowId], + null => null, + }; + }), + ); + + static final provider = NotifierProvider.family + .autoDispose( + TombstoneController.new, + ); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 87ab912..46e87bd 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -16,6 +16,7 @@ import "package:nexus/models/content/redaction.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/space_child.dart"; import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/content/tombstone.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/image_pack.dart"; @@ -64,7 +65,7 @@ enum EventType( avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), - tombstone("m.room.tombstone", Content.fromJson), + tombstone("m.room.tombstone", TombstoneContent.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), spaceChild("m.space.child", SpaceChildContent.fromJson), diff --git a/lib/models/content/tombstone.dart b/lib/models/content/tombstone.dart new file mode 100644 index 0000000..220d0b1 --- /dev/null +++ b/lib/models/content/tombstone.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "tombstone.freezed.dart"; +part "tombstone.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TombstoneContent({ + final String body = "", + required final String replacementRoom, +}) extends Content with _$TombstoneContent { + @override + Map toJson() => _$TombstoneContentToJson(this); + + factory TombstoneContent.fromJson(Map json) => + _$TombstoneContentFromJson(json); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 5b002d9..b37a126 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -14,6 +14,8 @@ import "package:nexus/helpers/extensions/send_failed.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/hooks/tap_recognizer.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/main.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; @@ -27,6 +29,7 @@ import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/poll_end.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/tombstone.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; @@ -140,6 +143,27 @@ class const EventRenderer( ], ); + // For rooms an upgrade links, i.e. the old and new rooms + Widget roomLink(String label, String linkedRoomId) => InkWell( + onTap: () => ref + .openLinkInfo(context, ( + identifier: linkedRoomId, + eventId: null, + // The sender is in both rooms, so their server can be joined + // through + via: [event.sender.substring(event.sender.indexOf(":") + 1)].lock, + )) + .onError(showError), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + decoration: .underline, + decorationColor: colorScheme.primary, + ), + ), + ); + final child = event.isShownAsMessage ? MessageRenderer( event, @@ -185,7 +209,25 @@ class const EventRenderer( Icons.interests, "changed the room avatar", ), - CreateContent() => generic(Icons.add, "created the room"), + CreateContent(:final predecessor) => generic( + Icons.add, + predecessor == null + ? "created the room" + : "created the room, replacing an older one", + details: [ + if (predecessor case PreviousRoom(:final roomId)) + roomLink("Go to old room", roomId), + ], + ), + TombstoneContent(:final body, :final replacementRoom) => generic( + Icons.upgrade, + "replaced the room", + details: [ + if (body.isNotEmpty) + Text(body, style: theme.textTheme.labelMedium), + roomLink("Go to new room", replacementRoom), + ], + ), PollEndContent() => switch (event.relatesTo) { final pollId? => switch (ref .watch( diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index efb8c20..d336831 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/tombstone.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; @@ -23,6 +24,7 @@ import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/search_drawer.dart"; import "package:nexus/widgets/sync_status_banner.dart"; +import "package:nexus/widgets/tombstone_banner.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/helpers/extensions/build_event_options.dart"; @@ -200,9 +202,14 @@ final class const RoomChat({ (settings) => settings.value?.swipeToReply ?? false, ), ); - final canReply = ref.watch( - PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), - ); + final tombstone = ref.watch(TombstoneController.provider(roomId)); + final canReply = + tombstone == null && + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ); return Scaffold( endDrawerEnableOpenDragGesture: false, @@ -308,71 +315,81 @@ final class const RoomChat({ child: MeasureSize( onChange: (size) => composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relatedEventRowId: - relatedEvent.value, - ) - .onError(showError), - onSendSticker: - ( - shortcode, - image, { - required shouldMention, - }) => notifier - .sendSticker( - shortcode, - image, - shouldMention: shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendPoll: - (poll, {required shouldMention}) => - notifier - .sendPoll( - poll, - shouldMention: shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendLocation: - (location, {required shouldMention}) => - notifier - .sendLocation( - location, - shouldMention: shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendBotCommand: (draft) => notifier - .sendBotCommand( - draft, - repliedEventRowId: repliedEventRowId, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () { - relatedEvent.value = null; - composerNode.requestFocus(); - }, - ), + child: switch (tombstone) { + final tombstone? => TombstoneBanner( + tombstone, + ), + null => Composer( + roomId, + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier + .send( + text, + tags: tags, + relationType: + relationType.value, + shouldMention: shouldMention, + relatedEventRowId: + relatedEvent.value, + ) + .onError(showError), + onSendSticker: + ( + shortcode, + image, { + required shouldMention, + }) => notifier + .sendSticker( + shortcode, + image, + shouldMention: shouldMention, + repliedEventRowId: + repliedEventRowId, + ) + .onError(showError), + onSendPoll: + (poll, {required shouldMention}) => + notifier + .sendPoll( + poll, + shouldMention: + shouldMention, + repliedEventRowId: + repliedEventRowId, + ) + .onError(showError), + onSendLocation: + ( + location, { + required shouldMention, + }) => notifier + .sendLocation( + location, + shouldMention: shouldMention, + repliedEventRowId: + repliedEventRowId, + ) + .onError(showError), + onSendBotCommand: (draft) => notifier + .sendBotCommand( + draft, + repliedEventRowId: + repliedEventRowId, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + }, ), ), ], diff --git a/lib/widgets/tombstone_banner.dart b/lib/widgets/tombstone_banner.dart new file mode 100644 index 0000000..8227ba7 --- /dev/null +++ b/lib/widgets/tombstone_banner.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/tombstone.dart"; +import "package:nexus/models/event.dart"; + +class const TombstoneBanner(final Event tombstone, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final content = tombstone.content; + final sender = tombstone.sender; + + return Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: .symmetric(horizontal: 16, vertical: 8), + margin: .only(top: 4), + child: Row( + spacing: 12, + children: [ + Icon(Icons.upgrade), + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "This room has been replaced and is no longer active.", + style: .new(fontWeight: .bold), + ), + if (content case TombstoneContent(:final body) + when body.isNotEmpty) + Text(body, style: theme.textTheme.bodySmall), + ], + ), + ), + if (content case TombstoneContent(:final replacementRoom)) + FilledButton.icon( + onPressed: () => ref + .openLinkInfo(context, ( + identifier: replacementRoom, + eventId: null, + via: [sender.substring(sender.indexOf(":") + 1)].lock, + )) + .onError(showError), + label: Text("New room"), + icon: Icon(Icons.open_in_new), + ), + ], + ), + ); + } +}