add pinned event and server ACL diffing

This commit is contained in:
Henry Hiles 2026-10-23 11:28:44 -04:00
commit 4e140e9fc7
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 340 additions and 46 deletions

View file

@ -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,
);
}

View file

@ -29,6 +29,8 @@ 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/generic_event.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/swipe_to_reply.dart";
import "package:nexus/widgets/timestamp.dart"; import "package:nexus/widgets/timestamp.dart";
@ -103,14 +105,23 @@ class const EventRenderer(
event.sender == previousEvent?.sender && event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id; event.pmp?.id == previousEvent?.pmp?.id;
GenericEventRenderer generic(IconData icon, String text) => GenericEventRenderer generic(
.new(icon, maxLines: maxLines, [ IconData icon,
WidgetSpan( String text, {
alignment: .middle, List<Widget> details = const [],
child: MessageDisplayname(event, shouldWrap: maxLines == null), }) => .new(
), icon,
TextSpan(text: text), maxLines: maxLines,
]); details: details,
trailing: Timestamp(event.timestamp),
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(event, shouldWrap: maxLines == null),
),
TextSpan(text: text),
],
);
final child = event.isShownAsMessage final child = event.isShownAsMessage
? MessageRenderer( ? MessageRenderer(
@ -177,13 +188,47 @@ class const EventRenderer(
.worldReadable => "all history visible (world readable)", .worldReadable => "all history visible (world readable)",
}}", }}",
), ),
PinnedEventsContent() => generic( PinnedEventsContent(:final pinnedEvents) => () {
Icons.push_pin, final before = switch (event.previousContent) {
"pinned/unpinned some events", PinnedEventsContent(:final pinnedEvents) => pinnedEvents,
), _ => const IList<String>.empty(),
ServerACLContent() => generic( };
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, Icons.list,
"updated the server ban list", "updated the server ban list",
details: [
ServerAclDiff(
content,
previous: switch (event.previousContent) {
final ServerACLContent previous => previous,
_ => null,
},
),
],
), ),
CanonicalAliasContent(:final alias, :final altAliases) => generic( CanonicalAliasContent(:final alias, :final altAliases) => generic(
Icons.numbers, Icons.numbers,
@ -270,7 +315,8 @@ class const EventRenderer(
crossAxisAlignment: .center, crossAxisAlignment: .center,
children: [ children: [
child, child,
if (child is! MessageRenderer) if (child is! MessageRenderer &&
child is! GenericEventRenderer)
Timestamp(event.timestamp), Timestamp(event.timestamp),
], ],
), ),

View file

@ -5,33 +5,68 @@ class const GenericEventRenderer(
final IconData icon, final IconData icon,
final List<InlineSpan> children, { final List<InlineSpan> children, {
final int? maxLines, 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<Widget> details = const [],
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) {
padding: .symmetric(vertical: 4), // Only previewing the event, so it should stay short
child: Row( final previewing = maxLines != null;
spacing: 8, final iconSize = IconTheme.of(context).size ?? 24;
mainAxisSize: .min,
children: [ return Padding(
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), padding: .symmetric(vertical: 4),
Flexible( child: Row(
child: Text.rich( spacing: 8,
TextSpan( mainAxisSize: .min,
children: children // At the top, by the text, rather than the middle of any details
.expandIndexed( crossAxisAlignment: .start,
(index, child) => [ children: [
if (index > 0) TextSpan(text: " "), Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
child, 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,
), ),
), ],
], ),
), );
); }
} }

View file

@ -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<String> pinned,
required final IList<String> 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),
),
],
);
}
}

View file

@ -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<String> before,
IList<String> 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,
),
],
);
}
}

View file

@ -1,6 +1,5 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.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:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter_hooks/flutter_hooks.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/better_when.dart";
import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/create_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/helpers/extensions/get_localpart.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.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) => void showMembershipSheet(MembershipAction action) =>
context.showAdaptiveSheet( context.showAdaptiveSheet(
(context) => HookBuilder( (context) => HookBuilder(
@ -407,8 +401,8 @@ final class const UserBottomSheet(
icon: Icon(Icons.check), icon: Icon(Icons.check),
label: Text("Accept knock"), label: Text("Accept knock"),
decoration: .styleFrom( decoration: .styleFrom(
backgroundColor: acceptScheme.primary, backgroundColor: theme.successScheme.primary,
foregroundColor: acceptScheme.onPrimary, foregroundColor: theme.successScheme.onPrimary,
), ),
), ),
), ),