diff --git a/lib/helpers/extensions/success_scheme.dart b/lib/helpers/extensions/success_scheme.dart new file mode 100644 index 0000000..77f83ae --- /dev/null +++ b/lib/helpers/extensions/success_scheme.dart @@ -0,0 +1,11 @@ +import "package:dynamic_color/dynamic_color.dart"; +import "package:material_ui/material_ui.dart"; + +extension SuccessScheme on ThemeData { + /// Green colors for positive things, like accepting knocks or new pins, + /// harmonized so they fit the current theme. + ColorScheme get successScheme => ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(colorScheme.primary), + brightness: brightness, + ); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 47750fa..b007862 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,8 @@ 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/generic_event.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"; import "package:nexus/widgets/timestamp.dart"; @@ -103,14 +105,23 @@ class const EventRenderer( event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; - GenericEventRenderer generic(IconData icon, String text) => - .new(icon, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), - TextSpan(text: text), - ]); + GenericEventRenderer generic( + IconData icon, + String text, { + List details = const [], + }) => .new( + icon, + maxLines: maxLines, + details: details, + trailing: Timestamp(event.timestamp), + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + TextSpan(text: text), + ], + ); final child = event.isShownAsMessage ? MessageRenderer( @@ -177,13 +188,47 @@ class const EventRenderer( .worldReadable => "all history visible (world readable)", }}", ), - PinnedEventsContent() => generic( - Icons.push_pin, - "pinned/unpinned some events", - ), - ServerACLContent() => generic( + PinnedEventsContent(:final pinnedEvents) => () { + final before = switch (event.previousContent) { + PinnedEventsContent(:final pinnedEvents) => pinnedEvents, + _ => const IList.empty(), + }; + final pinned = pinnedEvents + .where((id) => !before.contains(id)) + .toIList(); + final unpinned = before + .where((id) => !pinnedEvents.contains(id)) + .toIList(); + + return generic( + Icons.push_pin, + switch ((pinned.length, unpinned.length)) { + (1, 0) => "pinned a message", + (0, 1) => "unpinned a message", + _ => "changed the pinned messages", + }, + details: [ + PinnedEventsDiff( + roomId, + pinned: pinned, + unpinned: unpinned, + jumpToEvent: jumpToEvent, + ), + ], + ); + }(), + final ServerACLContent content => generic( Icons.list, "updated the server ban list", + details: [ + ServerAclDiff( + content, + previous: switch (event.previousContent) { + final ServerACLContent previous => previous, + _ => null, + }, + ), + ], ), CanonicalAliasContent(:final alias, :final altAliases) => generic( Icons.numbers, @@ -270,7 +315,8 @@ class const EventRenderer( crossAxisAlignment: .center, children: [ child, - if (child is! MessageRenderer) + if (child is! MessageRenderer && + child is! GenericEventRenderer) Timestamp(event.timestamp), ], ), diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 101913c..3922aed 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -5,33 +5,68 @@ class const GenericEventRenderer( final IconData icon, final List children, { final int? maxLines, + + /// Shown at the end of the text, like the event's timestamp. + final Widget? trailing, + + /// Shown under the text, like what changed in a state event. + final List details = const [], super.key, }) extends StatelessWidget { @override - Widget build(BuildContext context) => Padding( - padding: .symmetric(vertical: 4), - child: Row( - spacing: 8, - mainAxisSize: .min, - children: [ - Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), - Flexible( - child: Text.rich( - TextSpan( - children: children - .expandIndexed( - (index, child) => [ - if (index > 0) TextSpan(text: " "), - child, + Widget build(BuildContext context) { + // Only previewing the event, so it should stay short + final previewing = maxLines != null; + final iconSize = IconTheme.of(context).size ?? 24; + + return Padding( + padding: .symmetric(vertical: 4), + child: Row( + spacing: 8, + mainAxisSize: .min, + // At the top, by the text, rather than the middle of any details + crossAxisAlignment: .start, + children: [ + Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Flexible( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 4, + children: [ + // At least as tall as the icon, so they're centered together + ConstrainedBox( + constraints: .new(minHeight: iconSize), + child: Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Flexible( + child: Text.rich( + TextSpan( + children: children + .expandIndexed( + (index, child) => [ + if (index > 0) TextSpan(text: " "), + child, + ], + ) + .toList(), + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), + if (!previewing) ?trailing, ], - ) - .toList(), + ), + ), + if (!previewing) ...details, + ], ), - maxLines: maxLines ?? 999, - overflow: .ellipsis, ), - ), - ], - ), - ); + ], + ), + ); + } } diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/pinned_events_diff.dart new file mode 100644 index 0000000..2aa1a3d --- /dev/null +++ b/lib/widgets/renderers/pinned_events_diff.dart @@ -0,0 +1,110 @@ +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/event.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +/// A line for each message a pinned events event pinned, in green, and each one +/// it unpinned, in red and struck through. +class const PinnedEventsDiff( + final String roomId, { + required final IList pinned, + required final IList unpinned, + + /// If set, tapping a message jumps to it. + final JumpToEvent? jumpToEvent, + 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 changes = [ + ...pinned.map((eventId) => (eventId, unpinned: false)), + ...unpinned.map((eventId) => (eventId, unpinned: true)), + ]; + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...changes.take(maxShown).map((change) { + final color = change.unpinned + ? theme.colorScheme.error + : theme.successScheme.primary; + final decoration = change.unpinned + ? TextDecoration.lineThrough + : null; + + return DefaultTextStyle.merge( + style: style?.copyWith( + color: color, + decoration: decoration, + decorationColor: color, + ), + child: switch (ref.watch( + EventController.provider( + .new(roomId: roomId, eventId: change.$1), + ), + )) { + AsyncData(value: final event?) => InkWell( + borderRadius: .circular(4), + hoverColor: color.withValues(alpha: 0.12), + onTap: jumpToEvent == null + ? null + : () => jumpToEvent!(event.rowId), + child: Row( + mainAxisSize: .min, + children: [ + // Other events already say who sent them + if (event.content is MessageContent) ...[ + MessageDisplayname( + event, + clickable: false, + shouldWrap: false, + style: .new( + color: color, + fontWeight: .bold, + decoration: decoration, + decorationColor: color, + ), + ), + Text(": "), + ], + Flexible( + child: IgnorePointer( + child: EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), + ), + ), + ], + ), + ), + AsyncLoading() => SizedBox.square( + dimension: 12, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ), + // Like if it was deleted, or the server won't share it + _ => Text("Message not found"), + }, + ); + }), + if (changes.length > maxShown) + Text( + "+${changes.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/server_acl_diff.dart new file mode 100644 index 0000000..a61e868 --- /dev/null +++ b/lib/widgets/renderers/server_acl_diff.dart @@ -0,0 +1,98 @@ +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"; + +/// The servers newly denied (in red) and allowed (in green) by a server ACL +/// event, and those no longer denied or allowed, struck through. +class const ServerAclDiff( + final ServerACLContent content, { + 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? + when previous.allowIpLiterals != content.allowIpLiterals => + content.allowIpLiterals == true, + _ => null, + }; + + Widget? listDiff( + String label, + Color color, + IList before, + IList after, { + required bool ipLiterals, + }) { + 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; + + 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), + ), + if (ipLiterals) + Text( + "servers using IP addresses", + style: style?.copyWith(color: color), + ), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ?listDiff( + "Denied", + colorScheme.error, + previous?.deny ?? const .empty(), + content.deny, + ipLiterals: ipLiteralsAllowed == false, + ), + ?listDiff( + "Allowed", + theme.successScheme.primary, + previous?.allow ?? const .empty(), + content.allow, + ipLiterals: ipLiteralsAllowed == true, + ), + ], + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 58e29d2..1e21581 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,6 +1,5 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -17,6 +16,7 @@ import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; @@ -80,12 +80,6 @@ final class const UserBottomSheet( ), ); - // Green for accepting knocks, harmonized so it fits the current theme - final acceptScheme = ColorScheme.fromSeed( - seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), - brightness: theme.brightness, - ); - void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( (context) => HookBuilder( @@ -407,8 +401,8 @@ final class const UserBottomSheet( icon: Icon(Icons.check), label: Text("Accept knock"), decoration: .styleFrom( - backgroundColor: acceptScheme.primary, - foregroundColor: acceptScheme.onPrimary, + backgroundColor: theme.successScheme.primary, + foregroundColor: theme.successScheme.onPrimary, ), ), ),