From 0b9ddbfbc8b76dbd28d72d4c0e24be6c146042d3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 1 Apr 2026 16:29:19 -0400 Subject: [PATCH] add profile popovers --- lib/controllers/profile_controller.dart | 17 ++ lib/helpers/extensions/scheme_to_theme.dart | 9 + lib/helpers/extensions/show_context_menu.dart | 1 + lib/helpers/extensions/show_user_popover.dart | 18 ++ lib/widgets/avatar_or_hash.dart | 2 +- lib/widgets/chat_page/html/html.dart | 236 +++++++++--------- .../lazy_loading/message_avatar.dart | 17 +- .../lazy_loading/message_displayname.dart | 17 +- lib/widgets/chat_page/member_list.dart | 34 +-- lib/widgets/chat_page/user_popover.dart | 91 +++++++ pubspec.lock | 9 +- 11 files changed, 302 insertions(+), 149 deletions(-) create mode 100644 lib/controllers/profile_controller.dart create mode 100644 lib/helpers/extensions/show_user_popover.dart create mode 100644 lib/widgets/chat_page/user_popover.dart diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart new file mode 100644 index 0000000..120d4e4 --- /dev/null +++ b/lib/controllers/profile_controller.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/models/profile.dart"; + +class ProfileController extends AsyncNotifier { + final String userId; + ProfileController(this.userId); + + @override + Future build() { + final client = ref.watch(ClientController.provider.notifier); + return client.getProfile(userId); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family(ProfileController.new); +} diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index 08c0ba6..d106186 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -7,6 +7,15 @@ extension SchemeToTheme on ColorScheme { titleSpacing: 0, backgroundColor: surfaceContainerLow, ), + menuTheme: MenuThemeData( + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(primaryContainer), + ), + ), + chipTheme: ChipThemeData( + labelStyle: TextStyle(color: onPrimary), + color: WidgetStatePropertyAll(primary), + ), textTheme: ThemeData( fontFamilyFallback: ["sans", "emoji"], brightness: brightness, diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index f4762c3..7d8cab6 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,6 +9,7 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, + constraints: BoxConstraints.loose(Size.infinite), position: RelativeRect.fromLTRB( globalPosition.dx, globalPosition.dy, diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart new file mode 100644 index 0000000..1698879 --- /dev/null +++ b/lib/helpers/extensions/show_user_popover.dart @@ -0,0 +1,18 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/show_context_menu.dart"; +import "package:nexus/models/membership.dart"; +import "package:nexus/widgets/chat_page/user_popover.dart"; + +extension ShowUserPopover on BuildContext { + void showUserPopover(Membership member, {required Offset globalPosition}) => + showContextMenu( + globalPosition: globalPosition, + children: [ + PopupMenuItem( + enabled: false, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: IconTheme(data: IconThemeData(), child: UserPopover(member)), + ), + ], + ); +} diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 147c249..28662e2 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -50,7 +50,7 @@ class AvatarOrHash extends ConsumerWidget { ref.watch(CrossCacheController.provider), headers: ref.headers, ), - fit: BoxFit.contain, + fit: BoxFit.cover, errorBuilder: (_, _, _) => box, ), ), diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index cbd79ef..907ca7b 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -21,133 +21,135 @@ class Html extends ConsumerWidget { const Html(this.html, {this.textStyle, super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( - html, - textStyle: textStyle, - customWidgetBuilder: (element) { - if (element.attributes.keys.contains("data-mx-profile-fallback")) { - return SizedBox.shrink(); - } + Widget build(BuildContext context, WidgetRef ref) => SelectionArea( + child: HtmlWidget( + html, + textStyle: textStyle, + customWidgetBuilder: (element) { + if (element.attributes.keys.contains("data-mx-profile-fallback")) { + return SizedBox.shrink(); + } - if (element.attributes.keys.contains("data-mx-spoiler")) { - return InlineCustomWidget(child: SpoilerText(text: element.text)); - } + if (element.attributes.keys.contains("data-mx-spoiler")) { + return InlineCustomWidget(child: SpoilerText(text: element.text)); + } - final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; - final width = int.tryParse(element.attributes["width"] ?? ""); - final src = Uri.tryParse(element.attributes["src"] ?? "") - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(); + final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; + final width = int.tryParse(element.attributes["width"] ?? ""); + final src = Uri.tryParse(element.attributes["src"] ?? "") + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(); - return switch (element.localName) { - "code" => - element.parent?.localName == "pre" - ? element.outerHtml.contains("
") - ? Html( - """
${element.outerHtml.replaceAll("
", "\n")}
""", - ) - : CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) - : null, + return switch (element.localName) { + "code" => + element.parent?.localName == "pre" + ? element.outerHtml.contains("
") + ? Html( + """
${element.outerHtml.replaceAll("
", "\n")}
""", + ) + : CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) + : null, - "blockquote" => Quoted(Html(element.innerHtml)), + "blockquote" => Quoted(Html(element.innerHtml)), - "a" => - element.attributes["href"]?.mention == null - ? null - : InlineCustomWidget(child: MentionChip(element.text)), + "a" => + element.attributes["href"]?.mention == null + ? null + : InlineCustomWidget(child: MentionChip(element.text)), - "img" => - src == null - ? SizedBox.shrink() - : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - src, - child: Image( - image: CachedNetworkImage( - src, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, + "img" => + src == null + ? SizedBox.shrink() + : InlineCustomWidget( + alignment: PlaceholderAlignment.middle, + child: ExpandableImage( + src, + child: Image( + image: CachedNetworkImage( + src, + ref.watch(CrossCacheController.provider), + headers: ref.headers, ), + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), ), ), - ), - ("del" || - "h1" || - "h2" || - "h3" || - "h4" || - "h5" || - "h6" || - "p" || - "ul" || - "ol" || - "sup" || - "sub" || - "li" || - "b" || - "i" || - "u" || - "strong" || - "em" || - "s" || - "code" || - "hr" || - "br" || - "div" || - "table" || - "thead" || - "tbody" || - "tr" || - "th" || - "td" || - "caption" || - "pre" || - "span" || - "details" || - "summary") => - null, + ("del" || + "h1" || + "h2" || + "h3" || + "h4" || + "h5" || + "h6" || + "p" || + "ul" || + "ol" || + "sup" || + "sub" || + "li" || + "b" || + "i" || + "u" || + "strong" || + "em" || + "s" || + "code" || + "hr" || + "br" || + "div" || + "table" || + "thead" || + "tbody" || + "tr" || + "th" || + "td" || + "caption" || + "pre" || + "span" || + "details" || + "summary") => + null, - _ => SizedBox.shrink(), - }; - }, - customStylesBuilder: (element) => { - "width": "auto", - ...Map.fromEntries( - element.attributes - .mapTo?>( - (key, value) => switch (key) { - "data-mx-color" => MapEntry("color", value), - "data-mx-bg-color" => MapEntry("background-color", value), - _ => null, - }, - ) - .nonNulls, - ), - }, - onTapUrl: (url) => - ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), + _ => SizedBox.shrink(), + }; + }, + customStylesBuilder: (element) => { + "width": "auto", + ...Map.fromEntries( + element.attributes + .mapTo?>( + (key, value) => switch (key) { + "data-mx-color" => MapEntry("color", value), + "data-mx-bg-color" => MapEntry("background-color", value), + _ => null, + }, + ) + .nonNulls, + ), + }, + onTapUrl: (url) => + ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), + ), ); } diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index 9846867..ca1e06b 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -1,8 +1,9 @@ -import "package:flutter/widgets.dart"; +import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class MessageAvatar extends ConsumerWidget { @@ -14,10 +15,16 @@ class MessageAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) => ref .watch(AuthorController.provider(message)) .betterWhen( - data: (membership) => AvatarOrHash( - membership.avatarUrl, - membership.displayName, - height: height, + data: (membership) => InkWell( + onTapDown: (details) => context.showUserPopover( + membership, + globalPosition: details.globalPosition, + ), + child: AvatarOrHash( + membership.avatarUrl, + membership.displayName, + height: height, + ), ), loading: () => AvatarOrHash(null, message.authorId.substring(1), height: height), diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index ec56371..2ec867d 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -1,8 +1,9 @@ -import "package:flutter/widgets.dart"; +import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; class MessageDisplayname extends ConsumerWidget { final Message message; @@ -13,10 +14,16 @@ class MessageDisplayname extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) => ref .watch(AuthorController.provider(message)) .betterWhen( - data: (membership) => Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, + data: (membership) => InkWell( + onTapDown: (details) => context.showUserPopover( + membership, + globalPosition: details.globalPosition, + ), + child: Text( + "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", + style: style, + overflow: TextOverflow.ellipsis, + ), ), loading: () => Text(""), ); diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 2e0834f..0367a8a 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class MemberList extends ConsumerWidget { @@ -35,23 +36,24 @@ class MemberList extends ConsumerWidget { child: ListView( children: members .map( - (member) => ListTile( - onTap: () => showDialog( - context: context, - builder: (context) => - Dialog(child: Text("TODO: Open member popover")), + (member) => InkWell( + onTapDown: (details) => context.showUserPopover( + member, + globalPosition: details.globalPosition, ), - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, + child: ListTile( + leading: AvatarOrHash( + member.avatarUrl, + member.displayName, + ), + title: Text( + member.displayName, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + member.userId, + overflow: TextOverflow.ellipsis, + ), ), ), ) diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart new file mode 100644 index 0000000..9907741 --- /dev/null +++ b/lib/widgets/chat_page/user_popover.dart @@ -0,0 +1,91 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/profile_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/membership.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class UserPopover extends ConsumerWidget { + final Membership member; + const UserPopover(this.member, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + return IntrinsicWidth( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + spacing: 16, + mainAxisSize: MainAxisSize.min, + children: [ + AvatarOrHash(member.avatarUrl, member.displayName, height: 80), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + member.displayName, + style: textTheme.headlineSmall, + ), + SelectableText(member.userId, style: textTheme.titleSmall), + SizedBox(height: 4), + ref + .watch(ProfileController.provider(member.userId)) + .betterWhen( + loading: SizedBox.shrink, + data: (profile) => Row( + spacing: 4, + children: [ + for (final pronoun in profile.pronouns.where( + (pronoun) => pronoun.language == "en", + )) + Chip(label: Text(pronoun.summary)), + if (profile.timezone != null) + Chip(label: Text(profile.timezone!)), + ], + ), + ), + ], + ), + ), + ], + ), + Row( + spacing: 8, + children: [ + FilledButton.icon(onPressed: null, label: Text("Message")), + FilledButton.icon( + onPressed: null, + label: Text("Kick"), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.error, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), + ), + ), + ElevatedButton.icon( + onPressed: null, + label: Text("Ban"), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.errorContainer, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onErrorContainer, + ), + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 571a13b..1352319 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -490,11 +490,10 @@ packages: flutter_link_previewer: dependency: "direct main" description: - path: "packages/flutter_link_previewer" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git + name: flutter_link_previewer + sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" + url: "https://pub.dev" + source: hosted version: "4.2.0" flutter_lints: dependency: "direct dev"