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/server_acl.dart";
import "package:nexus/models/content/space_child.dart"; import "package:nexus/models/content/space_child.dart";
import "package:nexus/models/content/topic.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/sticker.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
@ -64,7 +65,7 @@ enum EventType(
avatar("m.room.avatar", AvatarContent.fromJson), avatar("m.room.avatar", AvatarContent.fromJson),
topic("m.room.topic", TopicContent.fromJson), topic("m.room.topic", TopicContent.fromJson),
name("m.room.name", NameContent.fromJson), name("m.room.name", NameContent.fromJson),
tombstone("m.room.tombstone", Content.fromJson), tombstone("m.room.tombstone", TombstoneContent.fromJson),
reaction("m.reaction", ReactionContent.fromJson), reaction("m.reaction", ReactionContent.fromJson),
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
spaceChild("m.space.child", SpaceChildContent.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/extensions/success_scheme.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/helpers/hooks/tap_recognizer.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/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.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/poll_end.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.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/content/topic.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.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 final child = event.isShownAsMessage
? MessageRenderer( ? MessageRenderer(
event, event,
@ -185,7 +209,25 @@ class const EventRenderer(
Icons.interests, Icons.interests,
"changed the room avatar", "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) { PollEndContent() => switch (event.relatesTo) {
final pollId? => switch (ref final pollId? => switch (ref
.watch( .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/member_list_opened.dart";
import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/tombstone.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_scroll.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/room_appbar.dart";
import "package:nexus/widgets/search_drawer.dart"; import "package:nexus/widgets/search_drawer.dart";
import "package:nexus/widgets/sync_status_banner.dart"; import "package:nexus/widgets/sync_status_banner.dart";
import "package:nexus/widgets/tombstone_banner.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/helpers/extensions/build_event_options.dart"; import "package:nexus/helpers/extensions/build_event_options.dart";
@ -200,9 +202,14 @@ final class const RoomChat({
(settings) => settings.value?.swipeToReply ?? false, (settings) => settings.value?.swipeToReply ?? false,
), ),
); );
final canReply = ref.watch( final tombstone = ref.watch(TombstoneController.provider(roomId));
PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), final canReply =
); tombstone == null &&
ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
);
return Scaffold( return Scaffold(
endDrawerEnableOpenDragGesture: false, endDrawerEnableOpenDragGesture: false,
@ -308,71 +315,81 @@ final class const RoomChat({
child: MeasureSize( child: MeasureSize(
onChange: (size) => onChange: (size) =>
composerSize.value = size.height, composerSize.value = size.height,
child: Composer( child: switch (tombstone) {
roomId, final tombstone? => TombstoneBanner(
node: composerNode, tombstone,
onSend: ),
( null => Composer(
text, { roomId,
required shouldMention, node: composerNode,
required tags, onSend:
}) => notifier (
.send( text, {
text, required shouldMention,
tags: tags, required tags,
relationType: relationType.value, }) => notifier
shouldMention: shouldMention, .send(
relatedEventRowId: text,
relatedEvent.value, tags: tags,
) relationType:
.onError(showError), relationType.value,
onSendSticker: shouldMention: shouldMention,
( relatedEventRowId:
shortcode, relatedEvent.value,
image, { )
required shouldMention, .onError(showError),
}) => notifier onSendSticker:
.sendSticker( (
shortcode, shortcode,
image, image, {
shouldMention: shouldMention, required shouldMention,
repliedEventRowId: }) => notifier
repliedEventRowId, .sendSticker(
) shortcode,
.onError(showError), image,
onSendPoll: shouldMention: shouldMention,
(poll, {required shouldMention}) => repliedEventRowId:
notifier repliedEventRowId,
.sendPoll( )
poll, .onError(showError),
shouldMention: shouldMention, onSendPoll:
repliedEventRowId: (poll, {required shouldMention}) =>
repliedEventRowId, notifier
) .sendPoll(
.onError(showError), poll,
onSendLocation: shouldMention:
(location, {required shouldMention}) => shouldMention,
notifier repliedEventRowId:
.sendLocation( repliedEventRowId,
location, )
shouldMention: shouldMention, .onError(showError),
repliedEventRowId: onSendLocation:
repliedEventRowId, (
) location, {
.onError(showError), required shouldMention,
onSendBotCommand: (draft) => notifier }) => notifier
.sendBotCommand( .sendLocation(
draft, location,
repliedEventRowId: repliedEventRowId, shouldMention: shouldMention,
) repliedEventRowId:
.onError(showError), repliedEventRowId,
relationType: relationType.value, )
relatedEvent: relatedEvent.value, .onError(showError),
onDismiss: () { onSendBotCommand: (draft) => notifier
relatedEvent.value = null; .sendBotCommand(
composerNode.requestFocus(); draft,
}, repliedEventRowId:
), repliedEventRowId,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
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),
),
],
),
);
}
}