From f60189bfb842fa8d9ddf919ac4f0e918ac525b5c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 7 Jun 2026 16:57:57 -0400 Subject: [PATCH 01/11] fix mark all as read behavior with subspaces --- lib/widgets/sidebar.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 591e25f..c70581e 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -209,7 +209,11 @@ class Sidebar extends HookConsumerWidget { actions: [ RoomMenu( selectedSpace.room, - children: selectedSpace.children, + children: selectedSpace.children.addAll( + selectedSpace.subSpaces + .map((element) => element.children) + .flattened, + ), ), ], ), From 562ce5d4ff3d32d9269cdf5258f3d6f74b2d1bad Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 7 Jun 2026 17:12:54 -0400 Subject: [PATCH 02/11] Add link to settings issue --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b97e9e7..a603886 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Invites -- [ ] Settings +- [ ] Settings ([#37](https://git.federated.nexus/Nexus/nexus/issues/37)) - [ ] Matrix: URIs vs Matrix.to links - [ ] Light/Dark mode - [ ] Remote Gomuks instance From e15d947fac79b6829ab55aa86703ab71a043a096 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 8 Jun 2026 11:03:34 -0400 Subject: [PATCH 03/11] rename FlashWrapper to HighlightWrapper --- ...flash_wrapper.dart => highlight_wrapper.dart} | 10 +++++----- lib/widgets/room_chat.dart | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) rename lib/widgets/{flash_wrapper.dart => highlight_wrapper.dart} (61%) diff --git a/lib/widgets/flash_wrapper.dart b/lib/widgets/highlight_wrapper.dart similarity index 61% rename from lib/widgets/flash_wrapper.dart rename to lib/widgets/highlight_wrapper.dart index 057565e..920db9e 100644 --- a/lib/widgets/flash_wrapper.dart +++ b/lib/widgets/highlight_wrapper.dart @@ -1,16 +1,16 @@ import "package:flutter/material.dart"; -class FlashWrapper extends StatelessWidget { +class HighlightWrapper extends StatelessWidget { final Widget child; - final bool isFlashing; - const FlashWrapper(this.child, {this.isFlashing = false, super.key}); + final bool isHighlighted; + const HighlightWrapper(this.child, {this.isHighlighted = false, super.key}); @override Widget build(BuildContext context) => ClipRRect( borderRadius: .all(.circular(12)), child: AnimatedContainer( - padding: isFlashing ? .all(8) : .all(0), - color: isFlashing + padding: isHighlighted ? .all(8) : .all(0), + color: isHighlighted ? Theme.of(context).colorScheme.onSurface.withAlpha(50) : Colors.transparent, duration: .new(milliseconds: 250), diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 2ab2cf7..75fa676 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -20,7 +20,7 @@ import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/flash_wrapper.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/loading.dart"; @@ -41,7 +41,7 @@ class RoomChat extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); - final flashingEvent = useState(null); + final highlightedEvent = useState(null); final composerSize = useState(64); @@ -426,7 +426,7 @@ class RoomChat extends HookConsumerWidget { itemBuilder: (_, index) { final event = value[index]; final previousEvent = value.getOrNull(index - 1); - return FlashWrapper( + return HighlightWrapper( EventRenderer( event, onTapReply: () async { @@ -440,10 +440,10 @@ class RoomChat extends HookConsumerWidget { duration: (_) => .new(milliseconds: 700), curve: (_) => Curves.easeInOut, ); - flashingEvent.value = replyId; + highlightedEvent.value = replyId; await Future.delayed(.new(seconds: 1), () { - if (flashingEvent.value == replyId) { - flashingEvent.value = null; + if (highlightedEvent.value == replyId) { + highlightedEvent.value = null; } }); }, @@ -457,8 +457,8 @@ class RoomChat extends HookConsumerWidget { "${event.sender}${event.pmp?.id}" == "${previousEvent?.sender}${previousEvent?.pmp?.id}", ), - isFlashing: - flashingEvent.value == event.eventId, + isHighlighted: + highlightedEvent.value == event.eventId, ); }, ), From d46646d7819c45e224a9bc535fc8ef1edf8b53e3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 9 Jun 2026 20:34:56 -0400 Subject: [PATCH 04/11] redesign user popover --- lib/helpers/extensions/show_user_popover.dart | 23 +- lib/widgets/avatar_or_hash.dart | 2 +- lib/widgets/html/mention_chip.dart | 7 +- lib/widgets/lazy_loading/message_avatar.dart | 31 +-- .../lazy_loading/message_displayname.dart | 1 - lib/widgets/member_list.dart | 31 ++- lib/widgets/renderers/membership.dart | 1 - lib/widgets/user_bottom_sheet.dart | 254 ++++++++++++++++++ lib/widgets/user_popover.dart | 224 --------------- 9 files changed, 302 insertions(+), 272 deletions(-) create mode 100644 lib/widgets/user_bottom_sheet.dart delete mode 100644 lib/widgets/user_popover.dart diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index e703721..1ea3015 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,25 +1,18 @@ import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/user_popover.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { void showUserPopover( MembershipContent member, String userId, { String? roomId, - required Offset globalPosition, - }) => showContextMenu( - globalPosition: globalPosition, - children: [ - PopupMenuItem( - enabled: false, - padding: .symmetric(horizontal: 16, vertical: 8), - child: IconTheme( - data: .new(), - child: UserPopover(member, userId, roomId: roomId), - ), - ), - ], + }) => showModalBottomSheet( + constraints: BoxConstraints.loose( + Size(500, View.of(this).physicalSize.height - 80), + ), + isScrollControlled: true, + context: this, + builder: (context) => UserBottomSheet(member, userId, roomId: roomId), ); } diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 4931684..20f4eac 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -24,7 +24,7 @@ class AvatarOrHash extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), + child: Center(child: Icon(Icons.person, size: height / 2)), ); final parsedAvatar = avatar?.mxcToHttps( diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index c757c3f..5a2b752 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -25,12 +25,7 @@ class MentionChip extends ConsumerWidget { : InkWell( onTapUp: (details) { if (membership != null) { - context.showUserPopover( - membership, - mention, - roomId: roomId, - globalPosition: details.globalPosition, - ); + context.showUserPopover(membership, mention, roomId: roomId); } }, child: IgnorePointer( diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index e7c7a77..62b5afd 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -12,21 +12,18 @@ class MessageAvatar extends ConsumerWidget { const MessageAvatar(this.event, {this.height = 24, super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => - switch (ref.watch(AuthorController.provider(event))) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTapUp: (details) => context.showUserPopover( - value, - event.sender, - roomId: event.roomId, - globalPosition: details.globalPosition, - ), - child: AvatarOrHash( - value.avatarUrl, - value.displayName ?? event.sender.localpart, - height: height, - ), - ), - _ => AvatarOrHash(null, event.sender.localpart, height: height), - }; + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( + AuthorController.provider(event), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTapUp: (details) => + context.showUserPopover(value, event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + height: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, height: height), + }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index ffa5dc0..4f00471 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -27,7 +27,6 @@ class MessageDisplayname extends ConsumerWidget { value, event.sender, roomId: event.roomId, - globalPosition: details.globalPosition, ) : null, child: Wrap( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index d3c21b6..6326421 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -14,6 +14,7 @@ import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; class MemberList extends HookConsumerWidget { final String roomId; @@ -138,8 +139,11 @@ class MemberList extends HookConsumerWidget { ), leading: AvatarOrHash( avatarUrl, + height: 36, displayName ?? - members[index].sender.localpart, + members[index] + .stateKey! + .localpart, ), ), _ => throw Exception( @@ -147,12 +151,25 @@ class MemberList extends HookConsumerWidget { ), }, onTap: (index) { - // context.showUserPopover( - // member.content as MembershipContent, - // member.stateKey!, - // roomId: roomId, - // globalPosition: details.globalPosition, - // ), + final member = members[index]; + if (member.content + case MembershipContent content) { + showModalBottomSheet( + constraints: BoxConstraints.loose( + Size( + 500, + (context.size?.height ?? 1000) - 80, + ), + ), + isScrollControlled: true, + context: context, + builder: (context) => UserBottomSheet( + content, + member.stateKey!, + roomId: roomId, + ), + ); + } }, ), ], diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index c8c91ba..ba8741f 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -25,7 +25,6 @@ class MembershipRenderer extends StatelessWidget { content, event.stateKey!, roomId: event.roomId, - globalPosition: details.globalPosition, ), child: Text( overflow: .ellipsis, diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart new file mode 100644 index 0000000..5cbb384 --- /dev/null +++ b/lib/widgets/user_bottom_sheet.dart @@ -0,0 +1,254 @@ +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/controllers/profile_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/requests/membership_action.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/expandable_image.dart"; + +class UserBottomSheet extends ConsumerWidget { + final MembershipContent member; + final String userId; + final String? roomId; + const UserBottomSheet(this.member, this.userId, {this.roomId, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final client = ref.watch(ClientController.provider.notifier); + + void showMembershipDialog(MembershipAction action) => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final actionReasonController = useTextEditingController(); + return AlertDialog( + title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Are you sure you want to ${action.name} $userId?"), + SizedBox(height: 12), + TextField( + textCapitalization: .sentences, + controller: actionReasonController, + decoration: .new( + labelText: "Reason for ${action.name} (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: action, + reason: actionReasonController.text, + ), + ) + .onError(showError); + }, + child: Text(toBeginningOfSentenceCase(action.name)), + ), + ], + ); + }, + ), + ); + + return Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + SizedBox(height: 18), + + ExpandableImage( + member.avatarUrl + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + height: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + ), + SelectableText( + userId, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: SizedBox.shrink, + data: (profile) => Column( + children: [ + Wrap( + crossAxisAlignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profile.pronouns + .where((pronoun) => pronoun.language == "en") + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + .flattened, + + if (profile.timezone != null) ...[ + if (profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profile.timezone!, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != ref.watch(ClientStateController.provider)?.userId && + roomId != null) ...[ + if (ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + ) && + member.status == .join || + member.status == .invite) + Padding( + padding: .only(bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + M3EButton.icon( + onPressed: () => showMembershipDialog(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.error, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipDialog(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.errorContainer, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onErrorContainer, + ), + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: null, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/user_popover.dart b/lib/widgets/user_popover.dart deleted file mode 100644 index 97d88c4..0000000 --- a/lib/widgets/user_popover.dart +++ /dev/null @@ -1,224 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:intl/intl.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/models/requests/membership_action.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/expandable_image.dart"; - -class UserPopover extends ConsumerWidget { - final MembershipContent member; - final String userId; - final String? roomId; - const UserPopover(this.member, this.userId, {this.roomId, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final client = ref.watch(ClientController.provider.notifier); - - void showMembershipDialog(MembershipAction action) => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (context) { - final actionReasonController = useTextEditingController(); - return AlertDialog( - title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text("Are you sure you want to ${action.name} $userId?"), - SizedBox(height: 12), - TextField( - textCapitalization: .sentences, - controller: actionReasonController, - decoration: .new( - labelText: "Reason for ${action.name} (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - client - .setMembership( - .new( - userId: userId, - roomId: roomId!, - action: action, - reason: actionReasonController.text, - ), - ) - .onError(showError); - }, - child: Text(toBeginningOfSentenceCase(action.name)), - ), - ], - ); - }, - ), - ); - - final actionButton = ButtonStyle( - padding: WidgetStatePropertyAll(.symmetric(horizontal: 24, vertical: 18)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: .circular(8)), - ), - ); - - return Column( - spacing: 16, - crossAxisAlignment: .stretch, - children: [ - Wrap( - alignment: .center, - spacing: 16, - runSpacing: 8, - children: [ - ExpandableImage( - member.avatarUrl - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value!.homeserverUrl!, - ), - ), - ) - .toString(), - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - height: 80, - ), - ), - Column( - children: [ - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineSmall, - ), - SelectableText(userId, style: textTheme.titleSmall), - SizedBox(height: 4), - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: SizedBox.shrink, - data: (profile) => Wrap( - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - for (final pronoun in profile.pronouns.where( - (pronoun) => pronoun.language == "en", - )) - Chip( - label: Text(pronoun.summary), - labelStyle: .new( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - if (profile.timezone != null) - Chip( - label: Text(profile.timezone!), - labelStyle: .new( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - ], - ), - if (userId != ref.watch(ClientStateController.provider)?.userId && - roomId != null) - Wrap( - alignment: .center, - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: null, - label: Text("Message"), - icon: Icon(Icons.message), - style: actionButton, - ), - - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - ) && - member.status == .join || - member.status == .invite) - FilledButton.icon( - onPressed: () => showMembershipDialog(.kick), - label: Text("Kick"), - style: actionButton.copyWith( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - icon: Icon(Icons.sports_martial_arts), - ), - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - roomId: roomId!, - action: .ban, - targetUser: userId, - ), - ), - )) - ElevatedButton.icon( - onPressed: () => showMembershipDialog( - member.status == .ban ? .unban : .ban, - ), - icon: Icon(Icons.gavel), - label: Text(member.status == .ban ? "Unban" : "Ban"), - style: actionButton.copyWith( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ], - ); - } -} From 8cea4b6cf371e128b7152474dfa0070ba6e882dc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 9 Jun 2026 20:37:21 -0400 Subject: [PATCH 05/11] fix user sheet on small screens --- lib/widgets/user_bottom_sheet.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 5cbb384..4a7f56b 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -118,9 +118,11 @@ class UserBottomSheet extends ConsumerWidget { SelectableText( member.displayName ?? userId.localpart, style: textTheme.headlineLarge, + textAlign: .center, ), SelectableText( userId, + textAlign: .center, style: textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -134,6 +136,7 @@ class UserBottomSheet extends ConsumerWidget { children: [ Wrap( crossAxisAlignment: .center, + alignment: .center, spacing: 4, runSpacing: 4, children: [ @@ -149,6 +152,7 @@ class UserBottomSheet extends ConsumerWidget { ), Text( pronoun.summary, + textAlign: .center, style: textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -169,6 +173,7 @@ class UserBottomSheet extends ConsumerWidget { ), Text( profile.timezone!, + textAlign: .center, style: textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), From abba60c28baa04036884176ef95710e31a51aafc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 9 Jun 2026 20:40:31 -0400 Subject: [PATCH 06/11] onTapUp -> onTap when possible --- lib/widgets/html/mention_chip.dart | 2 +- lib/widgets/lazy_loading/message_avatar.dart | 2 +- lib/widgets/lazy_loading/message_displayname.dart | 4 ++-- lib/widgets/renderers/membership.dart | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 5a2b752..8e0ce11 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -23,7 +23,7 @@ class MentionChip extends ConsumerWidget { return mention == null ? SizedBox.shrink() : InkWell( - onTapUp: (details) { + onTap: () { if (membership != null) { context.showUserPopover(membership, mention, roomId: roomId); } diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 62b5afd..b65e377 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -16,7 +16,7 @@ class MessageAvatar extends ConsumerWidget { AuthorController.provider(event), )) { AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTapUp: (details) => + onTap: () => context.showUserPopover(value, event.sender, roomId: event.roomId), child: AvatarOrHash( value.avatarUrl, diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index 4f00471..a817122 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -22,8 +22,8 @@ class MessageDisplayname extends ConsumerWidget { AuthorController.provider(event), )) { AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTapUp: clickable - ? (details) => context.showUserPopover( + onTap: clickable + ? () => context.showUserPopover( value, event.sender, roomId: event.roomId, diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index ba8741f..b2835b4 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -21,7 +21,7 @@ class MembershipRenderer extends StatelessWidget { return switch (event.content) { MembershipContent content => GenericEventRenderer(Icons.people, [ InkWell( - onTapUp: (details) => context.showUserPopover( + onTap: () => context.showUserPopover( content, event.stateKey!, roomId: event.roomId, From 795e9b6e9ba88e62b81ab5d9c234bc8d0e244927 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 9 Jun 2026 21:15:15 -0400 Subject: [PATCH 07/11] add l10n todo comment --- lib/widgets/user_bottom_sheet.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 4a7f56b..86c7fb2 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -141,7 +141,10 @@ class UserBottomSheet extends ConsumerWidget { runSpacing: 4, children: [ ...profile.pronouns - .where((pronoun) => pronoun.language == "en") + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) .mapIndexed( (index, pronoun) => [ if (index != 0) From 7757825e273ace6049813b2bdd7da2919e0a6e71 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 10 Jun 2026 10:40:21 -0400 Subject: [PATCH 08/11] add placeholder while profile loading --- lib/widgets/user_bottom_sheet.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 86c7fb2..d83fbb1 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -131,9 +131,11 @@ class UserBottomSheet extends ConsumerWidget { ref .watch(ProfileController.provider(userId)) .betterWhen( - loading: SizedBox.shrink, + loading: () => Text(""), data: (profile) => Column( children: [ + if (profile.timezone == null && profile.pronouns.isEmpty) + Text(""), Wrap( crossAxisAlignment: .center, alignment: .center, From e40de37b9d94399f4136ab592e831ee8efcc4e88 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 10 Jun 2026 10:41:25 -0400 Subject: [PATCH 09/11] move message button above kick/ban --- lib/widgets/user_bottom_sheet.dart | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index d83fbb1..d422e01 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -193,6 +193,20 @@ class UserBottomSheet extends ConsumerWidget { SizedBox(height: 8), if (userId != ref.watch(ClientStateController.provider)?.userId && roomId != null) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: null, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + if (ref.watch( PowerLevelController.provider( .membershipAction( @@ -205,7 +219,7 @@ class UserBottomSheet extends ConsumerWidget { member.status == .join || member.status == .invite) Padding( - padding: .only(bottom: 8), + padding: .only(top: 4), child: Row( mainAxisSize: MainAxisSize.max, spacing: 8, @@ -242,20 +256,6 @@ class UserBottomSheet extends ConsumerWidget { ].map((e) => Expanded(child: e)).toList(), ), ), - - Row( - children: [ - Expanded( - child: M3EButton.icon( - onPressed: null, - shape: .square, - style: .tonal, - icon: Icon(Icons.message), - label: Text("Message"), - ), - ), - ], - ), ], ], ), From bc307cbcda0a460b7aadb98a1fd5bc938d79a063 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 11 Jun 2026 12:50:47 -0400 Subject: [PATCH 10/11] up size of spaces in sidebar --- lib/widgets/sidebar.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index c70581e..56f8d57 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -132,6 +132,7 @@ class Sidebar extends HookConsumerWidget { }, short: true, icon: AvatarOrHash( + height: 28, space.room?.metadata?.avatar, fallback: space.icon == null ? null From ddc8db832662115e41471a1ff37432284f4e8afe Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 11 Jun 2026 13:41:06 -0400 Subject: [PATCH 11/11] some keyboard navigation fixes This still isnt perfect, it sometimes skips over nodes, needs investigation. --- lib/widgets/renderers/event.dart | 51 +++++++++++++++++++++++++++----- lib/widgets/room_chat.dart | 2 +- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index a4f659c..bcca961 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; @@ -24,7 +25,7 @@ import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; -class EventRenderer extends ConsumerWidget { +class EventRenderer extends HookConsumerWidget { final Event event; final bool textOnly; final bool isGrouped; @@ -46,6 +47,8 @@ class EventRenderer extends ConsumerWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); + final focusNode = useFocusNode(); + useListenable(focusNode); final child = event.redactedBy != null || event.relationType == "m.replace" ? null @@ -168,12 +171,44 @@ class EventRenderer extends ConsumerWidget { if (textOnly) child else ...[ - GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: Padding( - padding: isGrouped ? .zero : .only(top: 8), - child: child, + Builder( + builder: (context) => FocusableActionDetector( + focusNode: focusNode, + actions: contextMenuCallback == null + ? null + : { + ActivateIntent: CallbackAction( + onInvoke: (_) { + final renderBox = + context.findRenderObject() as RenderBox; + final topLeft = renderBox.localToGlobal( + Offset.zero, + ); + context.showContextMenu( + globalPosition: topLeft, + children: getEventOptions!(event).toList(), + ); + return null; + }, + ), + }, + child: Container( + decoration: BoxDecoration( + color: focusNode.hasPrimaryFocus + ? theme.colorScheme.surfaceContainerHighest + : null, + ), + child: GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 8, + ).copyWith(top: isGrouped ? 0 : 8), + child: child, + ), + ), + ), ), ), diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 75fa676..2817172 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -400,7 +400,7 @@ class RoomChat extends HookConsumerWidget { children: [ Positioned.fill( child: Padding( - padding: .symmetric(horizontal: 12), + padding: .symmetric(horizontal: 4), child: switch (controllerData) { AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView(