add profile popovers

This commit is contained in:
Henry Hiles 2026-04-01 16:29:19 -04:00
commit 0b9ddbfbc8
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
11 changed files with 302 additions and 149 deletions

View file

@ -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<Profile> {
final String userId;
ProfileController(this.userId);
@override
Future<Profile> build() {
final client = ref.watch(ClientController.provider.notifier);
return client.getProfile(userId);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ProfileController, Profile, String>(ProfileController.new);
}

View file

@ -7,6 +7,15 @@ extension SchemeToTheme on ColorScheme {
titleSpacing: 0, titleSpacing: 0,
backgroundColor: surfaceContainerLow, backgroundColor: surfaceContainerLow,
), ),
menuTheme: MenuThemeData(
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(primaryContainer),
),
),
chipTheme: ChipThemeData(
labelStyle: TextStyle(color: onPrimary),
color: WidgetStatePropertyAll(primary),
),
textTheme: ThemeData( textTheme: ThemeData(
fontFamilyFallback: ["sans", "emoji"], fontFamilyFallback: ["sans", "emoji"],
brightness: brightness, brightness: brightness,

View file

@ -9,6 +9,7 @@ extension ShowContextMenu on BuildContext {
showMenu( showMenu(
context: this, context: this,
constraints: BoxConstraints.loose(Size.infinite),
position: RelativeRect.fromLTRB( position: RelativeRect.fromLTRB(
globalPosition.dx, globalPosition.dx,
globalPosition.dy, globalPosition.dy,

View file

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

View file

@ -50,7 +50,7 @@ class AvatarOrHash extends ConsumerWidget {
ref.watch(CrossCacheController.provider), ref.watch(CrossCacheController.provider),
headers: ref.headers, headers: ref.headers,
), ),
fit: BoxFit.contain, fit: BoxFit.cover,
errorBuilder: (_, _, _) => box, errorBuilder: (_, _, _) => box,
), ),
), ),

View file

@ -21,133 +21,135 @@ class Html extends ConsumerWidget {
const Html(this.html, {this.textStyle, super.key}); const Html(this.html, {this.textStyle, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( Widget build(BuildContext context, WidgetRef ref) => SelectionArea(
html, child: HtmlWidget(
textStyle: textStyle, html,
customWidgetBuilder: (element) { textStyle: textStyle,
if (element.attributes.keys.contains("data-mx-profile-fallback")) { customWidgetBuilder: (element) {
return SizedBox.shrink(); if (element.attributes.keys.contains("data-mx-profile-fallback")) {
} return SizedBox.shrink();
}
if (element.attributes.keys.contains("data-mx-spoiler")) { if (element.attributes.keys.contains("data-mx-spoiler")) {
return InlineCustomWidget(child: SpoilerText(text: element.text)); return InlineCustomWidget(child: SpoilerText(text: element.text));
} }
final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; final height = int.tryParse(element.attributes["height"] ?? "") ?? 300;
final width = int.tryParse(element.attributes["width"] ?? ""); final width = int.tryParse(element.attributes["width"] ?? "");
final src = Uri.tryParse(element.attributes["src"] ?? "") final src = Uri.tryParse(element.attributes["src"] ?? "")
?.mxcToHttps( ?.mxcToHttps(
ref.watch( ref.watch(
ClientStateController.provider.select( ClientStateController.provider.select(
(value) => value?.homeserverUrl, (value) => value?.homeserverUrl,
), ),
) ?? ) ??
"", "",
) )
.toString(); .toString();
return switch (element.localName) { return switch (element.localName) {
"code" => "code" =>
element.parent?.localName == "pre" element.parent?.localName == "pre"
? element.outerHtml.contains("<br class=\"fake-break\">") ? element.outerHtml.contains("<br class=\"fake-break\">")
? Html( ? Html(
"""<pre>${element.outerHtml.replaceAll("<br class=\"fake-break\">", "\n")}</pre>""", """<pre>${element.outerHtml.replaceAll("<br class=\"fake-break\">", "\n")}</pre>""",
) )
: CodeBlock( : CodeBlock(
element.text, element.text,
lang: element.className.replaceAll("language-", ""), lang: element.className.replaceAll("language-", ""),
) )
: null, : null,
"blockquote" => Quoted(Html(element.innerHtml)), "blockquote" => Quoted(Html(element.innerHtml)),
"a" => "a" =>
element.attributes["href"]?.mention == null element.attributes["href"]?.mention == null
? null ? null
: InlineCustomWidget(child: MentionChip(element.text)), : InlineCustomWidget(child: MentionChip(element.text)),
"img" => "img" =>
src == null src == null
? SizedBox.shrink() ? SizedBox.shrink()
: InlineCustomWidget( : InlineCustomWidget(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: ExpandableImage( child: ExpandableImage(
src, src,
child: Image( child: Image(
image: CachedNetworkImage( image: CachedNetworkImage(
src, src,
ref.watch(CrossCacheController.provider), ref.watch(CrossCacheController.provider),
headers: ref.headers, headers: ref.headers,
),
errorBuilder: (_, error, _) => Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(context).colorScheme.error,
), ),
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" ||
("del" || "h1" ||
"h1" || "h2" ||
"h2" || "h3" ||
"h3" || "h4" ||
"h4" || "h5" ||
"h5" || "h6" ||
"h6" || "p" ||
"p" || "ul" ||
"ul" || "ol" ||
"ol" || "sup" ||
"sup" || "sub" ||
"sub" || "li" ||
"li" || "b" ||
"b" || "i" ||
"i" || "u" ||
"u" || "strong" ||
"strong" || "em" ||
"em" || "s" ||
"s" || "code" ||
"code" || "hr" ||
"hr" || "br" ||
"br" || "div" ||
"div" || "table" ||
"table" || "thead" ||
"thead" || "tbody" ||
"tbody" || "tr" ||
"tr" || "th" ||
"th" || "td" ||
"td" || "caption" ||
"caption" || "pre" ||
"pre" || "span" ||
"span" || "details" ||
"details" || "summary") =>
"summary") => null,
null,
_ => SizedBox.shrink(), _ => SizedBox.shrink(),
}; };
}, },
customStylesBuilder: (element) => { customStylesBuilder: (element) => {
"width": "auto", "width": "auto",
...Map.fromEntries( ...Map.fromEntries(
element.attributes element.attributes
.mapTo<MapEntry<String, String>?>( .mapTo<MapEntry<String, String>?>(
(key, value) => switch (key) { (key, value) => switch (key) {
"data-mx-color" => MapEntry("color", value), "data-mx-color" => MapEntry("color", value),
"data-mx-bg-color" => MapEntry("background-color", value), "data-mx-bg-color" => MapEntry("background-color", value),
_ => null, _ => null,
}, },
) )
.nonNulls, .nonNulls,
), ),
}, },
onTapUrl: (url) => onTapUrl: (url) =>
ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)),
),
); );
} }

View file

@ -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_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart"; import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.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"; import "package:nexus/widgets/avatar_or_hash.dart";
class MessageAvatar extends ConsumerWidget { class MessageAvatar extends ConsumerWidget {
@ -14,10 +15,16 @@ class MessageAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) => ref Widget build(BuildContext context, WidgetRef ref) => ref
.watch(AuthorController.provider(message)) .watch(AuthorController.provider(message))
.betterWhen( .betterWhen(
data: (membership) => AvatarOrHash( data: (membership) => InkWell(
membership.avatarUrl, onTapDown: (details) => context.showUserPopover(
membership.displayName, membership,
height: height, globalPosition: details.globalPosition,
),
child: AvatarOrHash(
membership.avatarUrl,
membership.displayName,
height: height,
),
), ),
loading: () => loading: () =>
AvatarOrHash(null, message.authorId.substring(1), height: height), AvatarOrHash(null, message.authorId.substring(1), height: height),

View file

@ -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_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart"; import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
class MessageDisplayname extends ConsumerWidget { class MessageDisplayname extends ConsumerWidget {
final Message message; final Message message;
@ -13,10 +14,16 @@ class MessageDisplayname extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) => ref Widget build(BuildContext context, WidgetRef ref) => ref
.watch(AuthorController.provider(message)) .watch(AuthorController.provider(message))
.betterWhen( .betterWhen(
data: (membership) => Text( data: (membership) => InkWell(
"${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", onTapDown: (details) => context.showUserPopover(
style: style, membership,
overflow: TextOverflow.ellipsis, globalPosition: details.globalPosition,
),
child: Text(
"${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}",
style: style,
overflow: TextOverflow.ellipsis,
),
), ),
loading: () => Text(""), loading: () => Text(""),
); );

View file

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/better_when.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"; import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget { class MemberList extends ConsumerWidget {
@ -35,23 +36,24 @@ class MemberList extends ConsumerWidget {
child: ListView( child: ListView(
children: members children: members
.map( .map(
(member) => ListTile( (member) => InkWell(
onTap: () => showDialog( onTapDown: (details) => context.showUserPopover(
context: context, member,
builder: (context) => globalPosition: details.globalPosition,
Dialog(child: Text("TODO: Open member popover")),
), ),
leading: AvatarOrHash( child: ListTile(
member.avatarUrl, leading: AvatarOrHash(
member.displayName, member.avatarUrl,
), member.displayName,
title: Text( ),
member.displayName, title: Text(
overflow: TextOverflow.ellipsis, member.displayName,
), overflow: TextOverflow.ellipsis,
subtitle: Text( ),
member.userId, subtitle: Text(
overflow: TextOverflow.ellipsis, member.userId,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
) )

View file

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

View file

@ -490,11 +490,10 @@ packages:
flutter_link_previewer: flutter_link_previewer:
dependency: "direct main" dependency: "direct main"
description: description:
path: "packages/flutter_link_previewer" name: flutter_link_previewer
ref: HEAD sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7"
resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" url: "https://pub.dev"
url: "https://github.com/Henry-Hiles/flutter_chat_ui" source: hosted
source: git
version: "4.2.0" version: "4.2.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"