diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 583f0f9..42da22c 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -11,6 +11,7 @@ import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.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/hooks/saving.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/topic.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/settings_category.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 eventSetting( - EventType type, { - required String title, - required String description, - required IconData icon, - required int fallback, - }) => powerLevelSetting( - title: title, - description: description, - icon: icon, - value: powerLevels.events[type.type] ?? fallback, - update: (level) => powerLevels.copyWith( - events: powerLevels.events.add(type.type, level), - ), - ); + Setting permissionSetting(PowerLevelPermission permission) => + powerLevelSetting( + title: permission.title, + description: permission.description, + icon: permission.icon, + value: permission.levelIn(powerLevels), + update: (level) => permission.withLevel(powerLevels, level), + ); + + /// A setting for the level needed to send [event], which otherwise needs [fallback]. + 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, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(type.type, level), + ), + ); + } return .new({ "Room": .new([ @@ -431,14 +440,7 @@ class RoomSettingsSectionsController(final String roomId) subtitle: Text(description), value: joinRules.joinRule, options: joinRuleOptions, - getName: (rule) => switch (rule) { - .public => "Public", - .invite => "Invite only", - .knock => "Knock", - .restricted => "Restricted", - .knockRestricted => "Restricted or knock", - .private => "Private", - }, + getName: (rule) => rule.displayName, onChanged: canSetJoinRules ? (rule) => setState( .joinRules, @@ -615,13 +617,7 @@ class RoomSettingsSectionsController(final String roomId) title: "Permissions", icon: Icons.admin_panel_settings, settings: .new([ - powerLevelSetting( - 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), - ), + permissionSetting(.usersDefault), header("Users", "Power levels of specific users.", Icons.group), ...users.map((entry) { final member = memberOf(entry.key); @@ -687,82 +683,21 @@ class RoomSettingsSectionsController(final String roomId) "Power levels needed to send messages and other timeline events.", Icons.chat, ), - eventSetting( - messageType, - title: "Send Messages", - description: "The power level needed to send messages.", - 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), - ), + eventSetting(.message, fallback: powerLevels.eventsDefault), + eventSetting(.sticker, fallback: powerLevels.eventsDefault), + eventSetting(.reaction, fallback: powerLevels.eventsDefault), + permissionSetting(.eventsDefault), header( "State Events", "Power levels needed to manage members and change the room's state.", Icons.settings, ), - eventSetting( - .pinnedEvents, - title: "Pin Messages", - description: "The power level needed to pin messages.", - icon: Icons.push_pin, - fallback: powerLevels.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), - ), + eventSetting(.pinnedEvents, fallback: powerLevels.stateDefault), + permissionSetting(.invite), + permissionSetting(.kick), + permissionSetting(.ban), + permissionSetting(.redact), + permissionSetting(.stateDefault), header( "Other Events", "Power levels for specific event types.", diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart index 5b431e6..eb81d16 100644 --- a/lib/helpers/extensions/displayname_color.dart +++ b/lib/helpers/extensions/displayname_color.dart @@ -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( 1, level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, diff --git a/lib/helpers/extensions/join_rule_name.dart b/lib/helpers/extensions/join_rule_name.dart new file mode 100644 index 0000000..d2cf4b5 --- /dev/null +++ b/lib/helpers/extensions/join_rule_name.dart @@ -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", + }; +} diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart new file mode 100644 index 0000000..8abff82 --- /dev/null +++ b/lib/models/power_level_permission.dart @@ -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 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; +} diff --git a/lib/widgets/renderers/change_line.dart b/lib/widgets/renderers/change_line.dart new file mode 100644 index 0000000..7195882 --- /dev/null +++ b/lib/widgets/renderers/change_line.dart @@ -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 removed = const .empty(), + final IList 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 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 items( + IList 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, + ], + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index b007862..87637c9 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -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.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, }; diff --git a/lib/widgets/renderers/join_rules_diff.dart b/lib/widgets/renderers/join_rules_diff.dart new file mode 100644 index 0000000..6d41c0b --- /dev/null +++ b/lib/widgets/renderers/join_rules_diff.dart @@ -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 roomIdsOf(JoinRulesContent? content) => + (content?.allow ?? const ISet.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 named(ISet 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, + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/power_levels_diff.dart b/lib/widgets/renderers/power_levels_diff.dart new file mode 100644 index 0000000..f90c917 --- /dev/null +++ b/lib/widgets/renderers/power_levels_diff.dart @@ -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 capped(Iterable 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), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/server_acl_diff.dart index a61e868..e232614 100644 --- a/lib/widgets/renderers/server_acl_diff.dart +++ b/lib/widgets/renderers/server_acl_diff.dart @@ -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 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,