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/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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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: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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue