diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 49a1734f0..73ccd9a9c 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3211,5 +3211,6 @@ "recordAVideo": "Record a video", "optionalMessage": "(Optional) message...", "notSupportedOnThisDevice": "Not supported on this device", - "enterNewChat": "Enter new chat" + "enterNewChat": "Enter new chat", + "approve": "Approve" } diff --git a/lib/config/themes.dart b/lib/config/themes.dart index d2a151c3b..826f6440a 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -75,6 +75,14 @@ abstract class FluffyThemes { ), contentPadding: const EdgeInsets.all(12), ), + chipTheme: ChipThemeData( + showCheckmark: false, + backgroundColor: colorScheme.surfaceContainer, + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + ), appBarTheme: AppBarTheme( toolbarHeight: isColumnMode ? 72 : 56, shadowColor: diff --git a/lib/pages/chat_details/participant_list_item.dart b/lib/pages/chat_details/participant_list_item.dart index 365c7bcf8..ec90fcf54 100644 --- a/lib/pages/chat_details/participant_list_item.dart +++ b/lib/pages/chat_details/participant_list_item.dart @@ -30,65 +30,68 @@ class ParticipantListItem extends StatelessWidget { ? L10n.of(context).moderator : ''; - return Opacity( - opacity: user.membership == Membership.join ? 1 : 0.5, - child: ListTile( - onTap: () => showMemberActionsPopupMenu(context: context, user: user), - title: Row( - children: [ - Expanded( + return ListTile( + onTap: () => showMemberActionsPopupMenu(context: context, user: user), + title: Row( + children: [ + Expanded( + child: Text( + user.calcDisplayname(), + overflow: TextOverflow.ellipsis, + ), + ), + if (permissionBatch.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: user.powerLevel >= 100 + ? theme.colorScheme.tertiary + : theme.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), child: Text( - user.calcDisplayname(), - overflow: TextOverflow.ellipsis, + permissionBatch, + style: theme.textTheme.labelSmall?.copyWith( + color: user.powerLevel >= 100 + ? theme.colorScheme.onTertiary + : theme.colorScheme.onTertiaryContainer, + ), ), ), - if (permissionBatch.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: user.powerLevel >= 100 - ? theme.colorScheme.tertiary - : theme.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, + membershipBatch == null + ? const SizedBox.shrink() + : Container( + padding: + const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), ), - ), - child: Text( - permissionBatch, - style: theme.textTheme.labelSmall?.copyWith( - color: user.powerLevel >= 100 - ? theme.colorScheme.onTertiary - : theme.colorScheme.onTertiaryContainer, - ), - ), - ), - membershipBatch == null - ? const SizedBox.shrink() - : Container( - padding: const EdgeInsets.all(4), - margin: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: theme.secondaryHeaderColor, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - membershipBatch, - style: theme.textTheme.labelSmall, + child: Center( + child: Text( + membershipBatch, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, ), ), ), - ], - ), - subtitle: Text( - user.id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - leading: Avatar( + ), + ], + ), + subtitle: Text( + user.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Opacity( + opacity: user.membership == Membership.join ? 1 : 0.5, + child: Avatar( mxContent: user.avatarUrl, name: user.calcDisplayname(), presenceUserId: user.stateKey, diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 59bec1e6f..af19d17c8 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -14,7 +14,6 @@ import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; import '../../config/themes.dart'; import '../../widgets/adaptive_dialogs/user_dialog.dart'; @@ -155,7 +154,7 @@ class ChatListViewBody extends StatelessWidget { child: ListView( padding: const EdgeInsets.symmetric( horizontal: 12.0, - vertical: 16.0, + vertical: 12.0, ), shrinkWrap: true, scrollDirection: Axis.horizontal, @@ -172,53 +171,15 @@ class ChatListViewBody extends StatelessWidget { ] .map( (filter) => Padding( - padding: - const EdgeInsets.symmetric(horizontal: 4), - child: HoverBuilder( - builder: (context, hovered) => - AnimatedScale( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - scale: hovered ? 1.1 : 1.0, - child: InkWell( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - onTap: () => - controller.setActiveFilter(filter), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: filter == - controller.activeFilter - ? theme.colorScheme.primary - : theme.colorScheme - .secondaryContainer, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - alignment: Alignment.center, - child: Text( - filter.toLocalizedString(context), - style: TextStyle( - fontWeight: filter == - controller.activeFilter - ? FontWeight.w500 - : FontWeight.normal, - color: filter == - controller.activeFilter - ? theme.colorScheme.onPrimary - : theme.colorScheme - .onSecondaryContainer, - ), - ), - ), - ), - ), + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + selected: filter == controller.activeFilter, + onSelected: (_) => + controller.setActiveFilter(filter), + label: + Text(filter.toLocalizedString(context)), ), ), ) diff --git a/lib/pages/chat_members/chat_members.dart b/lib/pages/chat_members/chat_members.dart index c777d286e..d1ab81a5a 100644 --- a/lib/pages/chat_members/chat_members.dart +++ b/lib/pages/chat_members/chat_members.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; @@ -7,6 +9,7 @@ import 'chat_members_view.dart'; class ChatMembersPage extends StatefulWidget { final String roomId; + const ChatMembersPage({required this.roomId, super.key}); @override @@ -17,12 +20,27 @@ class ChatMembersController extends State { List? members; List? filteredMembers; Object? error; + Membership membershipFilter = Membership.join; final TextEditingController filterController = TextEditingController(); + void setMembershipFilter(Membership membership) { + membershipFilter = membership; + setFilter(); + } + void setFilter([_]) async { final filter = filterController.text.toLowerCase().trim(); + final members = this + .members + ?.where( + (member) => + membershipFilter == Membership.join || + member.membership == membershipFilter, + ) + .toList(); + if (filter.isEmpty) { setState(() { filteredMembers = members @@ -42,7 +60,8 @@ class ChatMembersController extends State { }); } - void refreshMembers() async { + void refreshMembers([_]) async { + Logs().d('Load room members from', widget.roomId); try { setState(() { error = null; @@ -50,7 +69,7 @@ class ChatMembersController extends State { final participants = await Matrix.of(context) .client .getRoomById(widget.roomId) - ?.requestParticipants(); + ?.requestParticipants(Membership.values); if (!mounted) return; @@ -67,10 +86,30 @@ class ChatMembersController extends State { } } + StreamSubscription? _updateSub; + @override void initState() { super.initState(); refreshMembers(); + + _updateSub = Matrix.of(context) + .client + .onSync + .stream + .where( + (syncUpdate) => + syncUpdate.rooms?.join?[widget.roomId]?.timeline?.events + ?.any((state) => state.type == EventTypes.RoomMember) ?? + false, + ) + .listen(refreshMembers); + } + + @override + void dispose() { + _updateSub?.cancel(); + super.dispose(); } @override diff --git a/lib/pages/chat_members/chat_members_view.dart b/lib/pages/chat_members/chat_members_view.dart index be53f99c7..e1598344c 100644 --- a/lib/pages/chat_members/chat_members_view.dart +++ b/lib/pages/chat_members/chat_members_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import '../../widgets/layouts/max_width_body.dart'; @@ -11,6 +12,7 @@ import 'chat_members.dart'; class ChatMembersView extends StatelessWidget { final ChatMembersController controller; + const ChatMembersView(this.controller, {super.key}); @override @@ -84,29 +86,89 @@ class ChatMembersView extends StatelessWidget { : ListView.builder( shrinkWrap: true, itemCount: members.length + 1, - itemBuilder: (context, i) => i == 0 - ? Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - controller: controller.filterController, - onChanged: controller.setFilter, - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), + itemBuilder: (context, i) { + if (i == 0) { + final availableFilters = Membership.values + .where( + (membership) => + controller.members?.any( + (member) => member.membership == membership, + ) ?? + false, + ) + .toList(); + availableFilters + .sort((a, b) => a == Membership.join ? -1 : 1); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: controller.filterController, + onChanged: controller.setFilter, + decoration: InputDecoration( + filled: true, + fillColor: + theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + prefixIcon: const Icon(Icons.search_outlined), + hintText: L10n.of(context).search, ), - hintStyle: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - prefixIcon: const Icon(Icons.search_outlined), - hintText: L10n.of(context).search, ), ), - ) - : ParticipantListItem(members[i - 1]), + if (availableFilters.length > 1) + SizedBox( + height: 64, + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 12.0, + ), + scrollDirection: Axis.horizontal, + itemCount: availableFilters.length, + itemBuilder: (context, i) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + label: Text( + switch (availableFilters[i]) { + Membership.ban => + L10n.of(context).banned, + Membership.invite => + L10n.of(context).invited, + Membership.join => + L10n.of(context).all, + Membership.knock => + L10n.of(context).knocking, + Membership.leave => + L10n.of(context).leftTheChat, + }, + ), + selected: controller.membershipFilter == + availableFilters[i], + onSelected: (_) => + controller.setMembershipFilter( + availableFilters[i], + ), + ), + ), + ), + ), + ], + ); + } + i--; + return ParticipantListItem(members[i]); + }, ), ), ); diff --git a/lib/widgets/member_actions_popup_menu_button.dart b/lib/widgets/member_actions_popup_menu_button.dart index c89db0d23..00c3a7989 100644 --- a/lib/widgets/member_actions_popup_menu_button.dart +++ b/lib/widgets/member_actions_popup_menu_button.dart @@ -84,6 +84,17 @@ void showMemberActionsPopupMenu({ ], ), ), + if (user.membership == Membership.knock) + PopupMenuItem( + value: _MemberActions.approve, + child: Row( + children: [ + const Icon(Icons.how_to_reg_outlined), + const SizedBox(width: 18), + Text(L10n.of(context).approve), + ], + ), + ), PopupMenuItem( enabled: user.room.canChangePowerLevel && user.canChangeUserPowerLevel, value: _MemberActions.setRole, @@ -202,9 +213,14 @@ void showMemberActionsPopupMenu({ future: () => user.setPower(power), ); return; + case _MemberActions.approve: + await showFutureLoadingDialog( + context: context, + future: () => user.room.invite(user.id), + ); + return; case _MemberActions.kick: if (await showOkCancelAlertDialog( - useRootNavigator: false, context: context, title: L10n.of(context).areYouSure, okLabel: L10n.of(context).yes, @@ -220,7 +236,6 @@ void showMemberActionsPopupMenu({ return; case _MemberActions.ban: if (await showOkCancelAlertDialog( - useRootNavigator: false, context: context, title: L10n.of(context).areYouSure, okLabel: L10n.of(context).yes, @@ -268,7 +283,6 @@ void showMemberActionsPopupMenu({ return; case _MemberActions.unban: if (await showOkCancelAlertDialog( - useRootNavigator: false, context: context, title: L10n.of(context).areYouSure, okLabel: L10n.of(context).yes, @@ -290,6 +304,7 @@ enum _MemberActions { setRole, kick, ban, + approve, unban, report, }