forked from Nexus/nexus
parent
a09ae01b4e
commit
05d25869ef
6 changed files with 323 additions and 128 deletions
|
|
@ -20,6 +20,8 @@ extension SchemeToTheme on ColorScheme {
|
||||||
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
|
||||||
color: surfaceContainerHigh,
|
color: surfaceContainerHigh,
|
||||||
),
|
),
|
||||||
|
// So ink at the edges of a sheet doesn't spill past its rounded corners
|
||||||
|
bottomSheetTheme: .new(clipBehavior: .antiAlias),
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
backgroundColor: surfaceContainerLow,
|
backgroundColor: surfaceContainerLow,
|
||||||
|
|
|
||||||
83
lib/helpers/extensions/show_image_viewer.dart
Normal file
83
lib/helpers/extensions/show_image_viewer.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/extensions/save_attachment.dart";
|
||||||
|
import "package:nexus/helpers/extensions/size_to_string.dart";
|
||||||
|
import "package:nexus/helpers/mxc_image.dart";
|
||||||
|
import "package:nexus/models/requests/download_media.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
|
||||||
|
extension ShowImageViewer on BuildContext {
|
||||||
|
/// Shows the image from [request] fullscreen, zoomable.
|
||||||
|
///
|
||||||
|
/// If [downloadFilename] is set, a download button is shown, suggesting it.
|
||||||
|
void showImageViewer(
|
||||||
|
WidgetRef ref,
|
||||||
|
DownloadMediaRequest request, {
|
||||||
|
String? downloadFilename,
|
||||||
|
int? size,
|
||||||
|
}) => showDialog(
|
||||||
|
context: this,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: Navigator.of(context).pop,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
maxScale: 10,
|
||||||
|
child: Image(
|
||||||
|
errorBuilder: (_, error, stackTrace) => ErrorDialog(
|
||||||
|
"Loading failed for ${request.mxc}\nError: $error",
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
|
image: MxcImage(ref, request),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (size != null)
|
||||||
|
Align(
|
||||||
|
alignment: .topLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: .all(32),
|
||||||
|
child: Card(
|
||||||
|
margin: .zero,
|
||||||
|
child: Padding(
|
||||||
|
padding: .symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Text(size.sizeAsString),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: .topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: .all(32),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (downloadFilename != null)
|
||||||
|
M3EButton(
|
||||||
|
onPressed: () => context.saveAttachment(
|
||||||
|
ref,
|
||||||
|
request.mxc,
|
||||||
|
encrypted: request.encrypted,
|
||||||
|
filename: downloadFilename,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.download),
|
||||||
|
),
|
||||||
|
M3EButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
import "package:nexus/helpers/extensions/show_image_viewer.dart";
|
||||||
import "package:nexus/helpers/extensions/save_attachment.dart";
|
|
||||||
import "package:nexus/helpers/extensions/size_to_string.dart";
|
|
||||||
import "package:nexus/helpers/mxc_image.dart";
|
|
||||||
import "package:nexus/models/requests/download_media.dart";
|
import "package:nexus/models/requests/download_media.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
|
||||||
|
|
||||||
final class const ExpandableImage(
|
final class const ExpandableImage(
|
||||||
final DownloadMediaRequest? request, {
|
final DownloadMediaRequest? request, {
|
||||||
|
|
@ -18,72 +14,15 @@ final class const ExpandableImage(
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => InkWell(
|
Widget build(BuildContext context, WidgetRef ref) => InkWell(
|
||||||
onTap: request == null
|
onTap: switch (request) {
|
||||||
? null
|
final request? => () => context.showImageViewer(
|
||||||
: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => SafeArea(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: Navigator.of(context).pop,
|
|
||||||
child: InteractiveViewer(
|
|
||||||
maxScale: 10,
|
|
||||||
child: Image(
|
|
||||||
errorBuilder: (_, error, stackTrace) => ErrorDialog(
|
|
||||||
"Loading failed for ${request?.mxc}\nError: $error",
|
|
||||||
stackTrace,
|
|
||||||
),
|
|
||||||
image: MxcImage(ref, request!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (size != null)
|
|
||||||
Align(
|
|
||||||
alignment: .topLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: .all(32),
|
|
||||||
child: Card(
|
|
||||||
margin: .zero,
|
|
||||||
child: Padding(
|
|
||||||
padding: .symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: Text(size!.sizeAsString),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: .topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: .all(32),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
if (downloadFilename != null)
|
|
||||||
M3EButton(
|
|
||||||
onPressed: () => context.saveAttachment(
|
|
||||||
ref,
|
ref,
|
||||||
request!.mxc,
|
request,
|
||||||
encrypted: request!.encrypted,
|
downloadFilename: downloadFilename,
|
||||||
filename: downloadFilename,
|
size: size,
|
||||||
),
|
|
||||||
child: Icon(Icons.download),
|
|
||||||
),
|
|
||||||
M3EButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
lib/widgets/reaction_key.dart
Normal file
25
lib/widgets/reaction_key.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/mxc_image.dart";
|
||||||
|
|
||||||
|
/// A reaction's key, either an emoji or a custom (mxc) image.
|
||||||
|
class const ReactionKey(
|
||||||
|
final String reaction, {
|
||||||
|
required final double height,
|
||||||
|
final TextStyle? style,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) =>
|
||||||
|
reaction.startsWith("mxc://")
|
||||||
|
? Image(
|
||||||
|
height: height,
|
||||||
|
image: MxcImage(ref, .new(mxc: Uri.parse(reaction))),
|
||||||
|
errorBuilder: (context, _, _) => Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
size: height,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(reaction, style: style, overflow: .ellipsis);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
|
import "package:flutter/gestures.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/reactions.dart";
|
import "package:nexus/controllers/reactions.dart";
|
||||||
import "package:nexus/controllers/room_chat.dart";
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
import "package:nexus/helpers/mxc_image.dart";
|
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/widgets/reaction_key.dart";
|
||||||
|
import "package:nexus/widgets/reactions_popover.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
|
||||||
|
|
@ -36,7 +39,19 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
||||||
final selected =
|
final selected =
|
||||||
reactors?[reaction]?.contains(clientState!.userId) ??
|
reactors?[reaction]?.contains(clientState!.userId) ??
|
||||||
false;
|
false;
|
||||||
return Tooltip(
|
void showReactions(PositionedGestureDetails details) =>
|
||||||
|
context.showAdaptivePopover(
|
||||||
|
(_) => ReactionsPopover(
|
||||||
|
event,
|
||||||
|
initialReaction: reaction,
|
||||||
|
),
|
||||||
|
anchor: context.globalRectAt(details.globalPosition),
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapUp: showReactions,
|
||||||
|
onLongPressStart: showReactions,
|
||||||
|
child: Tooltip(
|
||||||
message: reactors?[reaction]?.join(", ") ?? "",
|
message: reactors?[reaction]?.join(", ") ?? "",
|
||||||
child: ChoiceChip(
|
child: ChoiceChip(
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
|
|
@ -46,21 +61,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: reaction.startsWith("mxc://")
|
child: ReactionKey(reaction, height: 20),
|
||||||
? Image(
|
|
||||||
height: 20,
|
|
||||||
image: MxcImage(
|
|
||||||
ref,
|
|
||||||
.new(mxc: Uri.parse(reaction)),
|
|
||||||
),
|
|
||||||
errorBuilder: (context, _, _) => Icon(
|
|
||||||
Icons.broken_image,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.error,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(reaction, overflow: .ellipsis),
|
|
||||||
),
|
),
|
||||||
Text(count.toString(), overflow: .ellipsis),
|
Text(count.toString(), overflow: .ellipsis),
|
||||||
],
|
],
|
||||||
|
|
@ -96,6 +97,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
144
lib/widgets/reactions_popover.dart
Normal file
144
lib/widgets/reactions_popover.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/reactions.dart";
|
||||||
|
import "package:nexus/controllers/user.dart";
|
||||||
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_image_viewer.dart";
|
||||||
|
import "package:nexus/widgets/reaction_key.dart";
|
||||||
|
|
||||||
|
/// Every reaction to an event in tabs, each listing who reacted with it.
|
||||||
|
class const ReactionsPopover(
|
||||||
|
final Event event, {
|
||||||
|
final String? initialReaction,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final reactions = ref.watch(
|
||||||
|
ReactionsController.provider(
|
||||||
|
.new(roomId: event.roomId, eventRowId: event.rowId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Ordered like the reaction row on the timeline
|
||||||
|
final order = event.reactions.keys.toIList();
|
||||||
|
final keys =
|
||||||
|
reactions.value?.keys.toIList().sortOrdered(
|
||||||
|
(a, b) => order.indexOf(a).compareTo(order.indexOf(b)),
|
||||||
|
) ??
|
||||||
|
const .empty();
|
||||||
|
|
||||||
|
final vsync = useSingleTickerProvider(keys: [keys.length]);
|
||||||
|
final tabController = useMemoized(
|
||||||
|
() => TabController(
|
||||||
|
length: keys.length,
|
||||||
|
initialIndex: keys
|
||||||
|
.indexOf(initialReaction ?? "")
|
||||||
|
.clamp(0, max(0, keys.length - 1)),
|
||||||
|
vsync: vsync,
|
||||||
|
),
|
||||||
|
[keys.length],
|
||||||
|
);
|
||||||
|
useEffect(() => tabController.dispose, [tabController]);
|
||||||
|
useListenable(tabController);
|
||||||
|
|
||||||
|
return reactions.betterWhen(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
|
data: (reactions) {
|
||||||
|
if (keys.isEmpty) return SizedBox.shrink();
|
||||||
|
|
||||||
|
final reaction = keys[tabController.index];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .stretch,
|
||||||
|
children: [
|
||||||
|
TabBar(
|
||||||
|
controller: tabController,
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: .start,
|
||||||
|
tabs: keys.map((reaction) {
|
||||||
|
void expand() => context.showImageViewer(
|
||||||
|
ref,
|
||||||
|
.new(mxc: Uri.parse(reaction)),
|
||||||
|
downloadFilename: reaction.split("/").last,
|
||||||
|
);
|
||||||
|
final isImage = reaction.startsWith("mxc://");
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTap: isImage ? expand : null,
|
||||||
|
onLongPress: isImage ? expand : null,
|
||||||
|
child: Tab(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ReactionKey(reaction, height: 20),
|
||||||
|
Text(reactions[reaction]!.length.toString()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
alignment: .topCenter,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
key: ValueKey(reaction),
|
||||||
|
padding: .symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: .stretch,
|
||||||
|
children: reactions[reaction]!
|
||||||
|
.map(
|
||||||
|
(userId) => Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final member = ref
|
||||||
|
.watch(
|
||||||
|
UserController.provider(
|
||||||
|
.new(
|
||||||
|
roomId: event.roomId,
|
||||||
|
userId: userId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value;
|
||||||
|
final name =
|
||||||
|
member?.displayName ?? userId.localpart;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: AvatarOrHash(
|
||||||
|
member?.avatarUrl,
|
||||||
|
name,
|
||||||
|
dimension: 40,
|
||||||
|
),
|
||||||
|
title: Text(name, overflow: .ellipsis),
|
||||||
|
subtitle: Text(userId, overflow: .ellipsis),
|
||||||
|
onTap: () => context.showUserPopover(
|
||||||
|
userId,
|
||||||
|
roomId: event.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue