add tombstone support

Fixes #147
This commit is contained in:
Henry Hiles 2026-11-03 16:53:28 -05:00
commit 029efe7c0a
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 225 additions and 70 deletions

View file

@ -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<Event?> {
@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, Event?, String>(
TombstoneController.new,
);
}

View file

@ -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),

View file

@ -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<String, Object?> toJson() => _$TombstoneContentToJson(this);
factory TombstoneContent.fromJson(Map<String, Object?> json) =>
_$TombstoneContentFromJson(json);
}

View file

@ -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(

View file

@ -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,8 +202,13 @@ 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(
@ -308,7 +315,11 @@ final class const RoomChat({
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: Composer(
child: switch (tombstone) {
final tombstone? => TombstoneBanner(
tombstone,
),
null => Composer(
roomId,
node: composerNode,
onSend:
@ -320,7 +331,8 @@ final class const RoomChat({
.send(
text,
tags: tags,
relationType: relationType.value,
relationType:
relationType.value,
shouldMention: shouldMention,
relatedEventRowId:
relatedEvent.value,
@ -345,14 +357,17 @@ final class const RoomChat({
notifier
.sendPoll(
poll,
shouldMention: shouldMention,
shouldMention:
shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
onSendLocation:
(location, {required shouldMention}) =>
notifier
(
location, {
required shouldMention,
}) => notifier
.sendLocation(
location,
shouldMention: shouldMention,
@ -363,7 +378,8 @@ final class const RoomChat({
onSendBotCommand: (draft) => notifier
.sendBotCommand(
draft,
repliedEventRowId: repliedEventRowId,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
relationType: relationType.value,
@ -373,6 +389,7 @@ final class const RoomChat({
composerNode.requestFocus();
},
),
},
),
),
],

View file

@ -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),
),
],
),
);
}
}