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
9 changed files with 591 additions and 168 deletions

View file

@ -11,6 +11,7 @@ import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/join_rule_name.dart";
import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -27,6 +28,7 @@ import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/topic.dart";
import "package:nexus/models/join_rule.dart"; import "package:nexus/models/join_rule.dart";
import "package:nexus/models/power_level_permission.dart";
import "package:nexus/models/setting.dart"; import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
@ -288,22 +290,29 @@ class RoomSettingsSectionsController(final String roomId)
), ),
); );
/// A setting for the level needed to send [type], which otherwise needs [fallback]. Setting permissionSetting(PowerLevelPermission permission) =>
Setting eventSetting( powerLevelSetting(
EventType type, { title: permission.title,
required String title, description: permission.description,
required String description, icon: permission.icon,
required IconData icon, value: permission.levelIn(powerLevels),
required int fallback, update: (level) => permission.withLevel(powerLevels, level),
}) => powerLevelSetting( );
title: title,
description: description, /// A setting for the level needed to send [event], which otherwise needs [fallback].
icon: icon, Setting eventSetting(PowerLevelEvent event, {required int fallback}) {
final type = event == .message ? messageType : event.types.first;
return powerLevelSetting(
title: event.title,
description: event.description,
icon: event.icon,
value: powerLevels.events[type.type] ?? fallback, value: powerLevels.events[type.type] ?? fallback,
update: (level) => powerLevels.copyWith( update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(type.type, level), events: powerLevels.events.add(type.type, level),
), ),
); );
}
return .new({ return .new({
"Room": .new([ "Room": .new([
@ -431,14 +440,7 @@ class RoomSettingsSectionsController(final String roomId)
subtitle: Text(description), subtitle: Text(description),
value: joinRules.joinRule, value: joinRules.joinRule,
options: joinRuleOptions, options: joinRuleOptions,
getName: (rule) => switch (rule) { getName: (rule) => rule.displayName,
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
},
onChanged: canSetJoinRules onChanged: canSetJoinRules
? (rule) => setState( ? (rule) => setState(
.joinRules, .joinRules,
@ -615,13 +617,7 @@ class RoomSettingsSectionsController(final String roomId)
title: "Permissions", title: "Permissions",
icon: Icons.admin_panel_settings, icon: Icons.admin_panel_settings,
settings: .new([ settings: .new([
powerLevelSetting( permissionSetting(.usersDefault),
title: "Default Power Level",
description: "The power level of users who aren't given one.",
icon: Icons.person,
value: powerLevels.usersDefault,
update: (level) => powerLevels.copyWith(usersDefault: level),
),
header("Users", "Power levels of specific users.", Icons.group), header("Users", "Power levels of specific users.", Icons.group),
...users.map((entry) { ...users.map((entry) {
final member = memberOf(entry.key); final member = memberOf(entry.key);
@ -687,82 +683,21 @@ class RoomSettingsSectionsController(final String roomId)
"Power levels needed to send messages and other timeline events.", "Power levels needed to send messages and other timeline events.",
Icons.chat, Icons.chat,
), ),
eventSetting( eventSetting(.message, fallback: powerLevels.eventsDefault),
messageType, eventSetting(.sticker, fallback: powerLevels.eventsDefault),
title: "Send Messages", eventSetting(.reaction, fallback: powerLevels.eventsDefault),
description: "The power level needed to send messages.", permissionSetting(.eventsDefault),
icon: Icons.message,
fallback: powerLevels.eventsDefault,
),
eventSetting(
.sticker,
title: "Send Stickers",
description: "The power level needed to send stickers.",
icon: Icons.sticky_note_2,
fallback: powerLevels.eventsDefault,
),
eventSetting(
.reaction,
title: "Send Reactions",
description: "The power level needed to react to messages.",
icon: Icons.add_reaction,
fallback: powerLevels.eventsDefault,
),
powerLevelSetting(
title: "Default for Events",
description:
"The power level needed to send events without their own.",
icon: Icons.more_horiz,
value: powerLevels.eventsDefault,
update: (level) => powerLevels.copyWith(eventsDefault: level),
),
header( header(
"State Events", "State Events",
"Power levels needed to manage members and change the room's state.", "Power levels needed to manage members and change the room's state.",
Icons.settings, Icons.settings,
), ),
eventSetting( eventSetting(.pinnedEvents, fallback: powerLevels.stateDefault),
.pinnedEvents, permissionSetting(.invite),
title: "Pin Messages", permissionSetting(.kick),
description: "The power level needed to pin messages.", permissionSetting(.ban),
icon: Icons.push_pin, permissionSetting(.redact),
fallback: powerLevels.stateDefault, permissionSetting(.stateDefault),
),
powerLevelSetting(
title: "Invite Users",
description: "The power level needed to invite users.",
icon: Icons.person_add,
value: powerLevels.invite,
update: (level) => powerLevels.copyWith(invite: level),
),
powerLevelSetting(
title: "Kick Users",
description: "The power level needed to kick users.",
icon: Icons.sports_martial_arts,
value: powerLevels.kick,
update: (level) => powerLevels.copyWith(kick: level),
),
powerLevelSetting(
title: "Ban Users",
description: "The power level needed to ban and unban users.",
icon: Icons.gavel,
value: powerLevels.ban,
update: (level) => powerLevels.copyWith(ban: level),
),
powerLevelSetting(
title: "Delete Others' Messages",
description: "The power level needed to remove others' messages.",
icon: Icons.delete,
value: powerLevels.redact,
update: (level) => powerLevels.copyWith(redact: level),
),
powerLevelSetting(
title: "Default for State Events",
description: "The power level needed to send state events without their own.",
icon: Icons.tune,
value: powerLevels.stateDefault,
update: (level) => powerLevels.copyWith(stateDefault: level),
),
header( header(
"Other Events", "Other Events",
"Power levels for specific event types.", "Power levels for specific event types.",

View file

@ -20,6 +20,15 @@ extension DisplaynameColor on WidgetRef {
), ),
}; };
/// Like [displaynameColor], for when [userId]'s [level] is already known,
/// like a level they're being given.
Color displaynameColorAt(String userId, double level) => switch (watch(
SettingsController.provider.select((settings) => settings.value?.nameColor),
)) {
.user => userId.colorHash,
.powerLevel || null => powerLevelColor(level),
};
static Color powerLevelColor(double level) => HSLColor.fromAHSL( static Color powerLevelColor(double level) => HSLColor.fromAHSL(
1, 1,
level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360,

View file

@ -0,0 +1,13 @@
import "package:nexus/models/join_rule.dart";
extension JoinRuleName on JoinRule {
/// What the rule is called in the UI, e.g. "Invite only".
String get displayName => switch (this) {
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
};
}

View file

@ -0,0 +1,115 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
enum PowerLevelPermission(
final String title,
final String description,
final IconData icon,
) {
usersDefault(
"Default Power Level",
"The power level of users who aren't given one.",
Icons.person,
),
eventsDefault(
"Default for Events",
"The power level needed to send events without their own.",
Icons.more_horiz,
),
stateDefault(
"Default for State Events",
"The power level needed to send state events without their own.",
Icons.tune,
),
invite(
"Invite Users",
"The power level needed to invite users.",
Icons.person_add,
),
kick(
"Kick Users",
"The power level needed to kick users.",
Icons.sports_martial_arts,
),
ban(
"Ban Users",
"The power level needed to ban and unban users.",
Icons.gavel,
),
redact(
"Delete Others' Messages",
"The power level needed to remove others' messages.",
Icons.delete,
),
notifyRoom(
"Notify Everyone",
"The power level needed to notify everyone in the room with @room.",
Icons.campaign,
);
int levelIn(PowerLevelsContent content) => switch (this) {
.usersDefault => content.usersDefault,
.eventsDefault => content.eventsDefault,
.stateDefault => content.stateDefault,
.invite => content.invite,
.kick => content.kick,
.ban => content.ban,
.redact => content.redact,
.notifyRoom => content.notifications?.room ?? 50,
};
PowerLevelsContent withLevel(PowerLevelsContent content, int level) =>
switch (this) {
.usersDefault => content.copyWith(usersDefault: level),
.eventsDefault => content.copyWith(eventsDefault: level),
.stateDefault => content.copyWith(stateDefault: level),
.invite => content.copyWith(invite: level),
.kick => content.copyWith(kick: level),
.ban => content.copyWith(ban: level),
.redact => content.copyWith(redact: level),
.notifyRoom => content.copyWith(
notifications: (content.notifications ?? const Notifications())
.copyWith(room: level),
),
};
}
enum PowerLevelEvent(
final String title,
final String description,
final IconData icon,
) {
message(
"Send Messages",
"The power level needed to send messages.",
Icons.message,
),
sticker(
"Send Stickers",
"The power level needed to send stickers.",
Icons.sticky_note_2,
),
reaction(
"Send Reactions",
"The power level needed to react to messages.",
Icons.add_reaction,
),
pinnedEvents(
"Pin Messages",
"The power level needed to pin messages.",
Icons.push_pin,
);
IList<EventType> get types => .new(switch (this) {
.message => [.message, .encrypted],
.sticker => [.sticker],
.reaction => [.reaction],
.pinnedEvents => [.pinnedEvents],
});
static PowerLevelEvent? of(String type) => values
.where((event) => event.types.any((eventType) => eventType.type == type))
.firstOrNull;
}

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/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.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/show_adaptive_menu.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/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";
import "package:nexus/models/content/create.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/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.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/pinned_events.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";
@ -28,7 +31,10 @@ import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/read_receipts_row.dart"; import "package:nexus/widgets/read_receipts_row.dart";
import "package:nexus/widgets/renderers/membership.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/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/pinned_events_diff.dart";
import "package:nexus/widgets/renderers/server_acl_diff.dart"; import "package:nexus/widgets/renderers/server_acl_diff.dart";
import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/swipe_to_reply.dart";
@ -105,6 +111,10 @@ class const EventRenderer(
event.sender == previousEvent?.sender && event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id; 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( GenericEventRenderer generic(
IconData icon, IconData icon,
String text, { String text, {
@ -167,18 +177,57 @@ class const EventRenderer(
"changed the room avatar", "changed the room avatar",
), ),
CreateContent() => generic(Icons.add, "created the room"), CreateContent() => generic(Icons.add, "created the room"),
PowerLevelsContent() => generic( final PowerLevelsContent content => generic(
Icons.power, Icons.power,
"changed the room's power levels", "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, Icons.rule,
"changed the room's join rules", "changed the room's join rules",
details: [
JoinRulesDiff(
content,
previous: switch (event.previousContent) {
final JoinRulesContent previous => previous,
_ => null,
},
), ),
TopicContent() => generic( ],
),
TopicContent(:final topic) => () {
final previous = switch (event.previousContent) {
TopicContent(:final topic) when topic.isNotEmpty => topic,
_ => null,
};
return generic(
Icons.description, Icons.description,
"updated the room topic", 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( HistoryVisibilityContent(:final historyVisibility) => generic(
Icons.history, Icons.history,
"changed the room's history visibility to ${switch (historyVisibility) { "changed the room's history visibility to ${switch (historyVisibility) {
@ -230,32 +279,71 @@ class const EventRenderer(
), ),
], ],
), ),
CanonicalAliasContent(:final alias, :final altAliases) => generic( CanonicalAliasContent(:final alias, :final altAliases) => () {
Icons.numbers, final previous = switch (event.previousContent) {
switch ([ final CanonicalAliasContent previous => previous,
if (event.previousContent case CanonicalAliasContent( _ => null,
alias: final prevAlias, };
altAliases: final prevAltAliases, final previousAltAliases =
)) ...[ previous?.altAliases ?? const ISet<String>.empty();
if (prevAlias != alias)
if (alias == null)
"removed the room's canonical alias"
else
"changed the room's canonical alias to $alias",
if (prevAltAliases return generic(
.remove(alias ?? "") Icons.numbers,
.remove(prevAlias ?? "") != previous == null
altAliases.remove(alias ?? "").remove(prevAlias ?? "")) ? "set the room's aliases"
"changed the room's aliases", : "changed the room's aliases",
] else ...[ details: [
if (alias != null) "set the room's canonical alias", if (previous?.alias != alias)
if (altAliases.isNotEmpty) "set the room's aliases", 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, _ => 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:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/server_acl.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 /// The servers newly denied (in red) and allowed (in green) by a server ACL
/// event, and those no longer denied or allowed, struck through. /// event, and those no longer denied or allowed, struck through.
@ -10,13 +11,9 @@ class const ServerAclDiff(
final ServerACLContent? previous, final ServerACLContent? previous,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
static const maxShown = 5;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
final colorScheme = theme.colorScheme;
final ipLiteralsAllowed = switch (previous) { final ipLiteralsAllowed = switch (previous) {
final previous? final previous?
@ -32,43 +29,24 @@ class const ServerAclDiff(
IList<String> after, { IList<String> after, {
required bool ipLiterals, required bool ipLiterals,
}) { }) {
// Sets, as an ACL can list hundreds of servers
final beforeSet = before.toISet(); final beforeSet = before.toISet();
final afterSet = after.toISet(); final afterSet = after.toISet();
final servers = [ final removed = before.where((server) => !afterSet.contains(server));
...after final added = after.where((server) => !beforeSet.contains(server));
.where((server) => !beforeSet.contains(server)) if (removed.isEmpty && added.isEmpty && !ipLiterals) return null;
.map((server) => (server, removed: false)),
...before
.where((server) => !afterSet.contains(server))
.map((server) => (server, removed: true)),
];
if (servers.isEmpty && !ipLiterals) return null;
return Wrap( return ChangeLine(
spacing: 8, label: label,
runSpacing: 2, removed: .new(removed),
children: [ added: .new(added),
Text(label, style: style?.copyWith(fontWeight: .bold)), removedColor: color,
...servers addedColor: color,
.take(maxShown) trailing: [
.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),
),
if (ipLiterals) if (ipLiterals)
Text( Text(
"servers using IP addresses", "servers using IP addresses",
style: style?.copyWith(color: color), style: theme.textTheme.labelMedium?.copyWith(color: color),
), ),
], ],
); );
@ -80,7 +58,7 @@ class const ServerAclDiff(
children: [ children: [
?listDiff( ?listDiff(
"Denied", "Denied",
colorScheme.error, theme.colorScheme.error,
previous?.deny ?? const .empty(), previous?.deny ?? const .empty(),
content.deny, content.deny,
ipLiterals: ipLiteralsAllowed == false, ipLiterals: ipLiteralsAllowed == false,