use a text span with a recognizer instead of a WidgetSpan

Fixes displaynames not ellipsising properly
This commit is contained in:
Henry Hiles 2026-10-26 12:45:54 -04:00
commit f971c50df0
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 139 additions and 67 deletions

View file

@ -12,4 +12,12 @@ extension GlobalRect on BuildContext {
_ => Rect.zero, _ => 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;
}
} }

View file

@ -5,20 +5,20 @@ import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/popover_layout.dart"; import "package:nexus/widgets/popover_layout.dart";
extension ShowAdaptivePopover on BuildContext { extension ShowAdaptivePopover on BuildContext {
/// Shows the widget from [builder] in a popover next to the widget this /// Shows the widget from [builder] in a popover next to [anchor], or the
/// context belongs to on desktop, or a bottom sheet on mobile. Use the /// widget this context belongs to, on desktop, or a bottom sheet on mobile.
/// context passed to [builder] to close it. /// Use the context passed to [builder] to close it.
Future<void> showAdaptivePopover(WidgetBuilder builder) { Future<void> showAdaptivePopover(WidgetBuilder builder, {Rect? anchor}) {
if (!isDesktop) return showAdaptiveSheet(builder); if (!isDesktop) return showAdaptiveSheet(builder);
final anchor = globalRect; final rect = anchor ?? globalRect;
return showDialog( return showDialog(
context: this, context: this,
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
useSafeArea: false, useSafeArea: false,
builder: (context) => CustomSingleChildLayout( builder: (context) => CustomSingleChildLayout(
delegate: PopoverLayout(anchor), delegate: PopoverLayout(rect),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const .new(maxWidth: 420, maxHeight: 600), constraints: const .new(maxWidth: 420, maxHeight: 600),
child: Material( child: Material(

View file

@ -3,6 +3,9 @@ import "package:nexus/helpers/extensions/show_adaptive_popover.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";
extension ShowUserPopover on BuildContext { extension ShowUserPopover on BuildContext {
void showUserPopover(String userId, {String? roomId}) => void showUserPopover(String userId, {String? roomId, Rect? anchor}) =>
showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); showAdaptivePopover(
(context) => UserBottomSheet(userId, roomId: roomId),
anchor: anchor,
);
} }

View file

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

View file

@ -4,11 +4,12 @@ import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_typing.dart"; import "package:nexus/controllers/room_typing.dart";
import "package:nexus/controllers/user.dart"; import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/get_localpart.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/bouncing_dots.dart";
import "package:nexus/widgets/user_span.dart";
class const TypingIndicator(final String roomId, {super.key}) class const TypingIndicator(final String roomId, {super.key})
extends ConsumerWidget { extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); 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 // Past three people, only the first two are named
final named = typing.length > 3 ? 2 : typing.length; final named = typing.length > 3 ? 2 : typing.length;
// One for each person who can be named
final recognizers = [
useTapRecognizer(),
useTapRecognizer(),
useTapRecognizer(),
];
final subjects = [ final subjects = [
...typing ...typing
.take(named) .take(named)
.map( .mapIndexed(
(typingUserId) => WidgetSpan( (index, typingUserId) => UserSpan(
alignment: .baseline, context,
baseline: .alphabetic, ref
child: Builder( .watch(
builder: (context) => InkWell( UserController.provider(
onTap: () => .new(roomId: roomId, userId: typingUserId),
context.showUserPopover(typingUserId, roomId: roomId), ),
child: Text( )
ref .value
.watch( ?.displayName ??
UserController.provider( typingUserId.localpart,
.new(roomId: roomId, userId: typingUserId), userId: typingUserId,
), roomId: roomId,
) recognizer: recognizers[index],
.value style: TextStyle(fontWeight: .bold),
?.displayName ??
typingUserId.localpart,
maxLines: 1,
overflow: .ellipsis,
softWrap: false,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: .bold,
),
),
),
),
), ),
), ),
if (typing.length > named) if (typing.length > named)

View file

@ -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/show_adaptive_menu.dart";
import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/helpers/hooks/chat_scroll.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/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.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/content/topic.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/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/read_receipts_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/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";
import "package:nexus/widgets/user_span.dart";
class const EventRenderer( class const EventRenderer(
final int eventRowId, { final int eventRowId, {
@ -64,6 +65,7 @@ class const EventRenderer(
final errorStyle = TextStyle(color: colorScheme.error); final errorStyle = TextStyle(color: colorScheme.error);
final focusNode = useFocusNode(); final focusNode = useFocusNode();
useListenable(focusNode); useListenable(focusNode);
final senderRecognizer = useTapRecognizer();
final (event, previousEvent) = ref.watch( final (event, previousEvent) = ref.watch(
RoomsController.provider.select((rooms) { RoomsController.provider.select((rooms) {
@ -130,10 +132,7 @@ class const EventRenderer(
details: details, details: details,
trailing: Timestamp(event.timestamp), trailing: Timestamp(event.timestamp),
[ [
WidgetSpan( UserSpan.sender(context, ref, event, recognizer: senderRecognizer),
alignment: .middle,
child: MessageDisplayname(event, shouldWrap: maxLines == null),
),
TextSpan(text: text), TextSpan(text: text),
], ],
); );

View file

@ -1,18 +1,18 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.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/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/content/membership.dart";
import "package:nexus/models/event.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/renderers/generic_event.dart";
import "package:nexus/widgets/user_span.dart";
class const MembershipRenderer( class const MembershipRenderer(
final Event event, { final Event event, {
final int? maxLines, final int? maxLines,
super.key, super.key,
}) extends ConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
assert( assert(
@ -20,6 +20,8 @@ class const MembershipRenderer(
"Make sure to only pass membership events to MembershipRenderer", "Make sure to only pass membership events to MembershipRenderer",
); );
final targetRecognizer = useTapRecognizer();
final senderRecognizer = useTapRecognizer();
final previous = switch (event.previousContent) { final previous = switch (event.previousContent) {
MembershipContent(:final status) => status, MembershipContent(:final status) => status,
_ => null, _ => null,
@ -30,22 +32,15 @@ class const MembershipRenderer(
Icons.people, Icons.people,
maxLines: maxLines, maxLines: maxLines,
[ [
WidgetSpan( UserSpan(
alignment: .middle, context,
child: InkWell( content.displayName ?? event.stateKey!.localpart,
onTap: () => context.showUserPopover( userId: event.stateKey!,
event.stateKey!, roomId: event.roomId,
roomId: event.roomId, recognizer: targetRecognizer,
), style: .new(
child: Text( color: ref.displaynameColor(event.roomId, event.stateKey!),
overflow: .ellipsis, fontWeight: .bold,
content.displayName ?? event.stateKey!.localpart,
maxLines: 1,
style: .new(
color: ref.displaynameColor(event.roomId, event.stateKey!),
fontWeight: .bold,
),
),
), ),
), ),
TextSpan( TextSpan(
@ -61,10 +56,7 @@ class const MembershipRenderer(
}} the room${event.sender == event.stateKey ? "" : " by"}", }} the room${event.sender == event.stateKey ? "" : " by"}",
), ),
if (event.sender != event.stateKey) if (event.sender != event.stateKey)
WidgetSpan( UserSpan.sender(context, ref, event, recognizer: senderRecognizer),
alignment: .middle,
child: MessageDisplayname(event, shouldWrap: maxLines == null),
),
if (content.reason != null) if (content.reason != null)
TextSpan(text: "for \"${content.reason}\""), TextSpan(text: "for \"${content.reason}\""),
], ],

View file

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