add pinned event and server ACL diffing
This commit is contained in:
parent
e5dd53ed6c
commit
4e140e9fc7
6 changed files with 340 additions and 46 deletions
11
lib/helpers/extensions/success_scheme.dart
Normal file
11
lib/helpers/extensions/success_scheme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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, [
|
||||
GenericEventRenderer generic(
|
||||
IconData icon,
|
||||
String text, {
|
||||
List<Widget> 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(
|
||||
PinnedEventsContent(:final pinnedEvents) => () {
|
||||
final before = switch (event.previousContent) {
|
||||
PinnedEventsContent(:final pinnedEvents) => pinnedEvents,
|
||||
_ => const IList<String>.empty(),
|
||||
};
|
||||
final pinned = pinnedEvents
|
||||
.where((id) => !before.contains(id))
|
||||
.toIList();
|
||||
final unpinned = before
|
||||
.where((id) => !pinnedEvents.contains(id))
|
||||
.toIList();
|
||||
|
||||
return generic(
|
||||
Icons.push_pin,
|
||||
"pinned/unpinned some events",
|
||||
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,
|
||||
),
|
||||
ServerACLContent() => generic(
|
||||
],
|
||||
);
|
||||
}(),
|
||||
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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,16 +5,42 @@ class const GenericEventRenderer(
|
|||
final IconData icon,
|
||||
final List<InlineSpan> 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<Widget> details = const [],
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
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(
|
||||
|
|
@ -31,7 +57,16 @@ class const GenericEventRenderer(
|
|||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
if (!previewing) ?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!previewing) ...details,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
lib/widgets/renderers/pinned_events_diff.dart
Normal file
110
lib/widgets/renderers/pinned_events_diff.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/widgets/renderers/server_acl_diff.dart
Normal file
98
lib/widgets/renderers/server_acl_diff.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue