add diffing for a lot of events

This commit is contained in:
Henry Hiles 2026-10-23 12:01:38 -04:00
commit 6e2d56ec75
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 591 additions and 168 deletions

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
/// A line of what a state event changed, like a room's old name, struck
/// through, then its new one.
class const ChangeLine({
/// What changed, e.g. `Denied`, in bold before the items.
final String? label,
final IList<String> removed = const .empty(),
final IList<String> added = const .empty(),
required final Color removedColor,
required final Color addedColor,
/// Whether to quote the items, like server names, but not whole sentences.
final bool quoted = true,
/// Shown after the items, like a change that isn't one of them.
final List<Widget> trailing = const [],
/// How many removed and how many added items to show, as some events, like
/// a room's first server ACL, can change hundreds.
final int maxShown = 5,
/// The most lines each item can take, as some, like topics, can be long.
final int maxLines = 2,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
Iterable<Widget> items(
IList<String> items, {
required Color color,
required bool struck,
}) => [
...items
.take(maxShown)
.map(
(item) => Text(
quoted ? "\"$item\"" : item,
maxLines: maxLines,
overflow: .ellipsis,
style: style?.copyWith(
color: color,
decoration: struck ? .lineThrough : null,
decorationColor: color,
),
),
),
if (items.length > maxShown)
Text(
"+${items.length - maxShown} more",
style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
];
return Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: .center,
children: [
if (label case final label?)
Text(label, style: style?.copyWith(fontWeight: .bold)),
...items(removed, color: removedColor, struck: true),
...items(added, color: addedColor, struck: false),
...trailing,
],
);
}
}

View file

@ -9,14 +9,17 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/pinned_events.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.dart";
@ -28,7 +31,10 @@ import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/read_receipts_row.dart";
import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/change_line.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/renderers/join_rules_diff.dart";
import "package:nexus/widgets/renderers/power_levels_diff.dart";
import "package:nexus/widgets/renderers/pinned_events_diff.dart";
import "package:nexus/widgets/renderers/server_acl_diff.dart";
import "package:nexus/widgets/swipe_to_reply.dart";
@ -105,6 +111,10 @@ class const EventRenderer(
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id;
// For what a state event changed, from and to
final removedColor = colorScheme.error;
final addedColor = theme.successScheme.primary;
GenericEventRenderer generic(
IconData icon,
String text, {
@ -167,18 +177,57 @@ class const EventRenderer(
"changed the room avatar",
),
CreateContent() => generic(Icons.add, "created the room"),
PowerLevelsContent() => generic(
final PowerLevelsContent content => generic(
Icons.power,
"changed the room's power levels",
details: [
PowerLevelsDiff(
roomId,
content,
previous: switch (event.previousContent) {
final PowerLevelsContent previous => previous,
_ => null,
},
),
],
),
JoinRulesContent() => generic(
final JoinRulesContent content => generic(
Icons.rule,
"changed the room's join rules",
details: [
JoinRulesDiff(
content,
previous: switch (event.previousContent) {
final JoinRulesContent previous => previous,
_ => null,
},
),
],
),
TopicContent() => generic(
Icons.description,
"updated the room topic",
),
TopicContent(:final topic) => () {
final previous = switch (event.previousContent) {
TopicContent(:final topic) when topic.isNotEmpty => topic,
_ => null,
};
return generic(
Icons.description,
topic.isEmpty
? "removed the room topic"
: previous == null
? "set the room topic"
: "changed the room topic",
details: [
ChangeLine(
removed: [?previous].lock,
added: [if (topic.isNotEmpty) topic].lock,
removedColor: removedColor,
addedColor: addedColor,
quoted: false,
),
],
);
}(),
HistoryVisibilityContent(:final historyVisibility) => generic(
Icons.history,
"changed the room's history visibility to ${switch (historyVisibility) {
@ -230,32 +279,71 @@ class const EventRenderer(
),
],
),
CanonicalAliasContent(:final alias, :final altAliases) => generic(
Icons.numbers,
switch ([
if (event.previousContent case CanonicalAliasContent(
alias: final prevAlias,
altAliases: final prevAltAliases,
)) ...[
if (prevAlias != alias)
if (alias == null)
"removed the room's canonical alias"
else
"changed the room's canonical alias to $alias",
CanonicalAliasContent(:final alias, :final altAliases) => () {
final previous = switch (event.previousContent) {
final CanonicalAliasContent previous => previous,
_ => null,
};
final previousAltAliases =
previous?.altAliases ?? const ISet<String>.empty();
if (prevAltAliases
.remove(alias ?? "")
.remove(prevAlias ?? "") !=
altAliases.remove(alias ?? "").remove(prevAlias ?? ""))
"changed the room's aliases",
] else ...[
if (alias != null) "set the room's canonical alias",
if (altAliases.isNotEmpty) "set the room's aliases",
return generic(
Icons.numbers,
previous == null
? "set the room's aliases"
: "changed the room's aliases",
details: [
if (previous?.alias != alias)
ChangeLine(
label: "Canonical alias",
removed: [?previous?.alias].lock,
added: [?alias].lock,
removedColor: removedColor,
addedColor: addedColor,
),
if (previousAltAliases != altAliases)
ChangeLine(
label: "Aliases",
removed: previousAltAliases
.difference(altAliases)
.toIList(),
added: altAliases
.difference(previousAltAliases)
.toIList(),
removedColor: removedColor,
addedColor: addedColor,
),
],
]) {
[] => "did something related to room aliases",
List prev => prev.join(" and "),
},
);
}(),
NameContent(:final name) => () {
final previous = switch (event.previousContent) {
NameContent(:final name) when name.isNotEmpty => name,
_ => null,
};
return generic(
Icons.title,
name.isEmpty
? "removed the room name"
: previous == null
? "set the room name"
: "changed the room name",
details: [
ChangeLine(
removed: [?previous].lock,
added: [if (name.isNotEmpty) name].lock,
removedColor: removedColor,
addedColor: addedColor,
),
],
);
}(),
EncryptionContent() => generic(
Icons.lock,
event.previousContent is EncryptionContent
? "changed the room's encryption settings"
: "enabled encryption",
),
_ => null,
};

View file

@ -0,0 +1,72 @@
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/controllers/rooms.dart";
import "package:nexus/helpers/extensions/join_rule_name.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/widgets/renderers/change_line.dart";
/// The join rule a join rules event changed to, and for restricted rooms, the
/// rooms whose members it let join, or stopped letting join.
class const JoinRulesDiff(
final JoinRulesContent content, {
final JoinRulesContent? previous,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final removedColor = theme.colorScheme.error;
final addedColor = theme.successScheme.primary;
ISet<String> roomIdsOf(JoinRulesContent? content) =>
(content?.allow ?? const ISet<AllowCondition>.empty())
.map((condition) => condition.roomId)
.nonNulls
.toISet();
final before = roomIdsOf(previous);
final after = roomIdsOf(content);
final removed = before.difference(after);
final added = after.difference(before);
// Rooms the user isn't in are shown by ID
final names = ref.watch(
RoomsController.provider.select(
(rooms) => IMap.fromEntries(
removed
.addAll(added)
.map(
(roomId) =>
MapEntry(roomId, rooms[roomId]?.metadata?.name ?? roomId),
),
),
),
);
IList<String> named(ISet<String> roomIds) =>
roomIds.map((roomId) => names[roomId] ?? roomId).toIList();
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
if (previous?.joinRule != content.joinRule)
ChangeLine(
removed: [?previous?.joinRule.displayName].lock,
added: [content.joinRule.displayName].lock,
removedColor: removedColor,
addedColor: addedColor,
quoted: false,
),
if (removed.isNotEmpty || added.isNotEmpty)
ChangeLine(
label: "Members of",
removed: named(removed),
added: named(added),
removedColor: removedColor,
addedColor: addedColor,
),
],
);
}
}

View file

@ -0,0 +1,141 @@
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/controllers/user.dart";
import "package:nexus/helpers/extensions/displayname_color.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/power_level_permission.dart";
/// Whose power levels, and which permissions' levels, a power levels event
/// changed, in green when raised, and red when lowered.
class const PowerLevelsDiff(
final String roomId,
final PowerLevelsContent content, {
final PowerLevelsContent? previous,
super.key,
}) extends ConsumerWidget {
static const maxShown = 5;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
final before = previous ?? PowerLevelsContent();
Text levels(int? from, int? to, {int? fromLevel, int? toLevel}) => Text(
"${from ?? "Default"} → ${to ?? "Default"}",
style: style?.copyWith(
color: switch ((fromLevel ?? from, toLevel ?? to)) {
(final from?, final to?) when to > from =>
theme.successScheme.primary,
(final from?, final to?) when to < from => theme.colorScheme.error,
_ => theme.colorScheme.onSurfaceVariant,
},
),
);
Iterable<Widget> capped(Iterable<Widget> lines) {
final list = lines.toList();
return [
...list.take(maxShown),
if (list.length > maxShown)
Text(
"+${list.length - maxShown} more",
style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
];
}
final users = before.users.keys
.toISet()
.addAll(content.users.keys)
.map(
(userId) => (
userId,
from: before.users[userId],
to: content.users[userId],
fromLevel: before.users[userId] ?? before.usersDefault,
toLevel: content.users[userId] ?? content.usersDefault,
),
)
.where((change) => change.from != change.to)
// The most powerful first
.toIList()
.sort((a, b) => b.toLevel.compareTo(a.toLevel));
final permissions = <(String, int?, int?)>[
...PowerLevelPermission.values.map(
(permission) => (
permission.title,
permission.levelIn(before),
permission.levelIn(content),
),
),
...before.events.keys
.toISet()
.addAll(content.events.keys)
.map(
(type) => (
PowerLevelEvent.of(type)?.title ?? type,
before.events[type],
content.events[type],
),
),
].where((permission) => permission.$2 != permission.$3);
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
...capped(
users.map(
(change) => Wrap(
spacing: 8,
crossAxisAlignment: .center,
children: [
Text(
ref
.watch(
UserController.provider(
.new(roomId: roomId, userId: change.$1),
),
)
.value
?.displayName ??
change.$1.localpart,
style: style?.copyWith(
fontWeight: .bold,
color: ref.displaynameColorAt(
change.$1,
change.toLevel.toDouble(),
),
),
),
levels(
change.from,
change.to,
fromLevel: change.fromLevel,
toLevel: change.toLevel,
),
],
),
),
),
...capped(
permissions.map(
(permission) => Wrap(
spacing: 8,
crossAxisAlignment: .center,
children: [
Text(permission.$1, style: style?.copyWith(fontWeight: .bold)),
levels(permission.$2, permission.$3),
],
),
),
),
],
);
}
}

View file

@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/server_acl.dart";
import "package:nexus/widgets/renderers/change_line.dart";
/// The servers newly denied (in red) and allowed (in green) by a server ACL
/// event, and those no longer denied or allowed, struck through.
@ -10,13 +11,9 @@ class const ServerAclDiff(
final ServerACLContent? previous,
super.key,
}) extends StatelessWidget {
static const maxShown = 5;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
final colorScheme = theme.colorScheme;
final ipLiteralsAllowed = switch (previous) {
final previous?
@ -32,43 +29,24 @@ class const ServerAclDiff(
IList<String> after, {
required bool ipLiterals,
}) {
// Sets, as an ACL can list hundreds of servers
final beforeSet = before.toISet();
final afterSet = after.toISet();
final servers = [
...after
.where((server) => !beforeSet.contains(server))
.map((server) => (server, removed: false)),
...before
.where((server) => !afterSet.contains(server))
.map((server) => (server, removed: true)),
];
if (servers.isEmpty && !ipLiterals) return null;
final removed = before.where((server) => !afterSet.contains(server));
final added = after.where((server) => !beforeSet.contains(server));
if (removed.isEmpty && added.isEmpty && !ipLiterals) return null;
return Wrap(
spacing: 8,
runSpacing: 2,
children: [
Text(label, style: style?.copyWith(fontWeight: .bold)),
...servers
.take(maxShown)
.map(
(server) => Text(
"\"${server.$1}\"",
style: style?.copyWith(
color: color,
decoration: server.removed ? .lineThrough : null,
),
),
),
if (servers.length > maxShown)
Text(
"+${servers.length - maxShown} more",
style: style?.copyWith(color: colorScheme.onSurfaceVariant),
),
return ChangeLine(
label: label,
removed: .new(removed),
added: .new(added),
removedColor: color,
addedColor: color,
trailing: [
if (ipLiterals)
Text(
"servers using IP addresses",
style: style?.copyWith(color: color),
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
);
@ -80,7 +58,7 @@ class const ServerAclDiff(
children: [
?listDiff(
"Denied",
colorScheme.error,
theme.colorScheme.error,
previous?.deny ?? const .empty(),
content.deny,
ipLiterals: ipLiteralsAllowed == false,