diff --git a/lib/helpers/extensions/global_rect.dart b/lib/helpers/extensions/global_rect.dart index 91c8305..4f2169f 100644 --- a/lib/helpers/extensions/global_rect.dart +++ b/lib/helpers/extensions/global_rect.dart @@ -12,4 +12,12 @@ extension GlobalRect on BuildContext { _ => Rect.zero, }; } + + /// [position] on screen as an empty rect, like where text was tapped, to + /// open a popover there. + Rect globalRectAt(Offset position) { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + return overlay.globalToLocal(position) & .zero; + } } diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart index 8a100e8..3d2a94c 100644 --- a/lib/helpers/extensions/show_adaptive_popover.dart +++ b/lib/helpers/extensions/show_adaptive_popover.dart @@ -5,20 +5,20 @@ import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/widgets/popover_layout.dart"; extension ShowAdaptivePopover on BuildContext { - /// Shows the widget from [builder] in a popover next to the widget this - /// context belongs to on desktop, or a bottom sheet on mobile. Use the - /// context passed to [builder] to close it. - Future showAdaptivePopover(WidgetBuilder builder) { + /// Shows the widget from [builder] in a popover next to [anchor], or the + /// widget this context belongs to, on desktop, or a bottom sheet on mobile. + /// Use the context passed to [builder] to close it. + Future showAdaptivePopover(WidgetBuilder builder, {Rect? anchor}) { if (!isDesktop) return showAdaptiveSheet(builder); - final anchor = globalRect; + final rect = anchor ?? globalRect; return showDialog( context: this, barrierColor: Colors.transparent, useSafeArea: false, builder: (context) => CustomSingleChildLayout( - delegate: PopoverLayout(anchor), + delegate: PopoverLayout(rect), child: ConstrainedBox( constraints: const .new(maxWidth: 420, maxHeight: 600), child: Material( diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index a56417e..ccf6898 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -3,6 +3,9 @@ import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(String userId, {String? roomId}) => - showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); + void showUserPopover(String userId, {String? roomId, Rect? anchor}) => + showAdaptivePopover( + (context) => UserBottomSheet(userId, roomId: roomId), + anchor: anchor, + ); } diff --git a/lib/helpers/hooks/tap_recognizer.dart b/lib/helpers/hooks/tap_recognizer.dart new file mode 100644 index 0000000..97c5534 --- /dev/null +++ b/lib/helpers/hooks/tap_recognizer.dart @@ -0,0 +1,11 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A recognizer for a tappable span in text, like a name, disposed with the +/// widget. +TapGestureRecognizer useTapRecognizer() { + final recognizer = useMemoized(TapGestureRecognizer.new); + useEffect(() => recognizer.dispose, [recognizer]); + + return recognizer; +} diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart index e9eda3f..9624937 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -4,11 +4,12 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/room_typing.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/widgets/bouncing_dots.dart"; +import "package:nexus/widgets/user_span.dart"; class const TypingIndicator(final String roomId, {super.key}) - extends ConsumerWidget { + extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -16,36 +17,31 @@ class const TypingIndicator(final String roomId, {super.key}) // Past three people, only the first two are named final named = typing.length > 3 ? 2 : typing.length; + // One for each person who can be named + final recognizers = [ + useTapRecognizer(), + useTapRecognizer(), + useTapRecognizer(), + ]; final subjects = [ ...typing .take(named) - .map( - (typingUserId) => WidgetSpan( - alignment: .baseline, - baseline: .alphabetic, - child: Builder( - builder: (context) => InkWell( - onTap: () => - context.showUserPopover(typingUserId, roomId: roomId), - child: Text( - ref - .watch( - UserController.provider( - .new(roomId: roomId, userId: typingUserId), - ), - ) - .value - ?.displayName ?? - typingUserId.localpart, - maxLines: 1, - overflow: .ellipsis, - softWrap: false, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: .bold, - ), - ), - ), - ), + .mapIndexed( + (index, typingUserId) => UserSpan( + context, + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + userId: typingUserId, + roomId: roomId, + recognizer: recognizers[index], + style: TextStyle(fontWeight: .bold), ), ), if (typing.length > named) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 6fd3310..49c7b19 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -11,6 +11,7 @@ import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; @@ -26,7 +27,6 @@ import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/read_receipts_row.dart"; @@ -39,6 +39,7 @@ 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"; +import "package:nexus/widgets/user_span.dart"; class const EventRenderer( final int eventRowId, { @@ -64,6 +65,7 @@ class const EventRenderer( final errorStyle = TextStyle(color: colorScheme.error); final focusNode = useFocusNode(); useListenable(focusNode); + final senderRecognizer = useTapRecognizer(); final (event, previousEvent) = ref.watch( RoomsController.provider.select((rooms) { @@ -130,10 +132,7 @@ class const EventRenderer( details: details, trailing: Timestamp(event.timestamp), [ - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), TextSpan(text: text), ], ); diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index fe845cf..17f8128 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,18 +1,18 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/user_span.dart"; class const MembershipRenderer( final Event event, { final int? maxLines, super.key, -}) extends ConsumerWidget { +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { assert( @@ -20,6 +20,8 @@ class const MembershipRenderer( "Make sure to only pass membership events to MembershipRenderer", ); + final targetRecognizer = useTapRecognizer(); + final senderRecognizer = useTapRecognizer(); final previous = switch (event.previousContent) { MembershipContent(:final status) => status, _ => null, @@ -30,22 +32,15 @@ class const MembershipRenderer( Icons.people, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: InkWell( - onTap: () => context.showUserPopover( - event.stateKey!, - roomId: event.roomId, - ), - child: Text( - overflow: .ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: .new( - color: ref.displaynameColor(event.roomId, event.stateKey!), - fontWeight: .bold, - ), - ), + UserSpan( + context, + content.displayName ?? event.stateKey!.localpart, + userId: event.stateKey!, + roomId: event.roomId, + recognizer: targetRecognizer, + style: .new( + color: ref.displaynameColor(event.roomId, event.stateKey!), + fontWeight: .bold, ), ), TextSpan( @@ -61,10 +56,7 @@ class const MembershipRenderer( }} the room${event.sender == event.stateKey ? "" : " by"}", ), if (event.sender != event.stateKey) - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), if (content.reason != null) TextSpan(text: "for \"${content.reason}\""), ], diff --git a/lib/widgets/user_span.dart b/lib/widgets/user_span.dart new file mode 100644 index 0000000..4fb2f9c --- /dev/null +++ b/lib/widgets/user_span.dart @@ -0,0 +1,63 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/author.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; + +/// A user's [name] in text, opening their popover when tapped. Unlike a widget +/// in text, it wraps and ellipsizes with the rest of it. +class UserSpan extends TextSpan { + UserSpan( + BuildContext context, + String name, { + required String userId, + required TapGestureRecognizer recognizer, + String? roomId, + super.style, + super.children, + }) : super( + text: name, + recognizer: recognizer + ..onTapUp = (details) { + if (!context.mounted) return; + + context.showUserPopover( + userId, + roomId: roomId, + anchor: context.globalRectAt(details.globalPosition), + ); + }, + ); + + factory UserSpan.sender( + BuildContext context, + WidgetRef ref, + Event event, { + required TapGestureRecognizer recognizer, + }) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return UserSpan( + context, + ref.watch(AuthorController.provider(event)).value?.displayName ?? + event.sender.localpart, + userId: event.sender, + roomId: event.roomId, + recognizer: recognizer, + style: TextStyle(color: color, fontWeight: .bold), + children: [ + if (event.pmp != null) + TextSpan( + text: " (via ${event.sender})", + recognizer: recognizer, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + ), + ], + ); + } +}