use a text span with a recognizer instead of a WidgetSpan
Fixes displaynames not ellipsising properly
This commit is contained in:
parent
1efbc131b4
commit
f971c50df0
8 changed files with 139 additions and 67 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
lib/helpers/hooks/tap_recognizer.dart
Normal file
11
lib/helpers/hooks/tap_recognizer.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}\""),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
63
lib/widgets/user_span.dart
Normal file
63
lib/widgets/user_span.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue