From 5c23453e665ed8804b445b5f792dc6efc3e14f08 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 14 Jul 2024 16:49:46 +0200 Subject: [PATCH] feat: New spaces and chat list design --- assets/l10n/intl_en.arb | 13 + lib/config/app_config.dart | 1 - lib/config/routes.dart | 5 - lib/config/setting_keys.dart | 1 - lib/pages/chat_list/chat_list.dart | 531 +++++----- lib/pages/chat_list/chat_list_body.dart | 478 +++++---- lib/pages/chat_list/chat_list_header.dart | 194 ++-- lib/pages/chat_list/chat_list_item.dart | 176 ++-- lib/pages/chat_list/chat_list_view.dart | 206 +--- lib/pages/chat_list/space_view.dart | 918 ++++++++---------- lib/pages/chat_list/start_chat_fab.dart | 88 -- lib/pages/chat_list/utils/on_chat_tap.dart | 127 --- .../settings_style/settings_style_view.dart | 6 - lib/widgets/avatar.dart | 98 +- lib/widgets/layouts/two_column_layout.dart | 4 +- lib/widgets/matrix.dart | 4 - linux/my_application.cc | 2 +- 17 files changed, 1270 insertions(+), 1582 deletions(-) delete mode 100644 lib/pages/chat_list/start_chat_fab.dart delete mode 100644 lib/pages/chat_list/utils/on_chat_tap.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index cfd26a7c..ab0ba910 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -196,6 +196,19 @@ "supportedVersions": {} } }, + "countChatsAndCountParticipants": "{chats} chats and {participants} participants", + "@countChatsAndCountParticipants": { + "type": "text", + "placeholders": { + "chats": {}, + "participants": {} + } + }, + "noMoreChatsFound": "No more chats found...", + "joinedChats": "Joined chats", + "unread": "Unread", + "space": "Space", + "spaces": "Spaces", "banFromChat": "Ban from chat", "@banFromChat": { "type": "text", diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 841d810e..6ee20d33 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -44,7 +44,6 @@ abstract class AppConfig { static bool hideRedactedEvents = false; static bool hideUnknownEvents = true; static bool hideUnimportantStateEvents = true; - static bool separateChatTypes = false; static bool autoplayImages = true; static bool sendTypingNotifications = true; static bool sendPublicReadReceipts = true; diff --git a/lib/config/routes.dart b/lib/config/routes.dart index d11cd56d..4aaf63a0 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -92,12 +92,8 @@ abstract class AppRoutes { FluffyThemes.isColumnMode(context) && state.fullPath?.startsWith('/rooms/settings') == false ? TwoColumnLayout( - displayNavigationRail: - state.path?.startsWith('/rooms/settings') != true, mainView: ChatList( activeChat: state.pathParameters['roomid'], - displayNavigationRail: - state.path?.startsWith('/rooms/settings') != true, ), sideView: child, ) @@ -175,7 +171,6 @@ abstract class AppRoutes { ? TwoColumnLayout( mainView: const Settings(), sideView: child, - displayNavigationRail: false, ) : child, ), diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 7c0e50df..5b795b08 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -4,7 +4,6 @@ abstract class SettingKeys { static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents'; static const String hideUnimportantStateEvents = 'chat.fluffy.hideUnimportantStateEvents'; - static const String separateChatTypes = 'chat.fluffy.separateChatTypes'; static const String sentry = 'sentry'; static const String theme = 'theme'; static const String amoledEnabled = 'amoled_enabled'; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 93f8d4ad..9181b1df 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -10,12 +10,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_shortcuts/flutter_shortcuts.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:uni_links/uni_links.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat/send_file_dialog.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; @@ -35,7 +36,6 @@ import 'package:fluffychat/utils/tor_stub.dart' enum SelectMode { normal, share, - select, } enum PopupMenuAction { @@ -49,19 +49,32 @@ enum PopupMenuAction { enum ActiveFilter { allChats, + unread, groups, - messages, spaces, } +extension LocalizedActiveFilter on ActiveFilter { + String toLocalizedString(BuildContext context) { + switch (this) { + case ActiveFilter.allChats: + return L10n.of(context)!.all; + case ActiveFilter.unread: + return L10n.of(context)!.unread; + case ActiveFilter.groups: + return L10n.of(context)!.groups; + case ActiveFilter.spaces: + return L10n.of(context)!.spaces; + } + } +} + class ChatList extends StatefulWidget { static BuildContext? contextForVoip; - final bool displayNavigationRail; final String? activeChat; const ChatList({ super.key, - this.displayNavigationRail = false, required this.activeChat, }); @@ -77,85 +90,240 @@ class ChatListController extends State StreamSubscription? _intentUriStreamSubscription; - bool get displayNavigationBar => - !FluffyThemes.isColumnMode(context) && - (spaces.isNotEmpty || AppConfig.separateChatTypes); - - String? activeSpaceId; - - void resetActiveSpaceId() { - setState(() { - selectedRoomIds.clear(); - activeSpaceId = null; - }); + void createNewSpace() { + context.push('/rooms/newspace'); } - void setActiveSpace(String? spaceId) { - setState(() { - selectedRoomIds.clear(); - activeSpaceId = spaceId; - activeFilter = ActiveFilter.spaces; - }); - } + ActiveFilter activeFilter = ActiveFilter.allChats; - void createNewSpace() async { - final spaceId = await context.push('/rooms/newspace'); - if (spaceId != null) { - setActiveSpace(spaceId); + String? _activeSpaceId; + String? get activeSpaceId => _activeSpaceId; + + void setActiveSpace(String spaceId) => setState(() { + _activeSpaceId = spaceId; + }); + void clearActiveSpace() => setState(() { + _activeSpaceId = null; + }); + + void addChatAction() async { + if (activeSpaceId == null) { + context.go('/rooms/newprivatechat'); + return; } - } - int get selectedIndex { - switch (activeFilter) { - case ActiveFilter.allChats: - case ActiveFilter.messages: - return 0; - case ActiveFilter.groups: - return 1; - case ActiveFilter.spaces: - return AppConfig.separateChatTypes ? 2 : 1; - } - } + final roomType = await showConfirmationDialog( + context: context, + title: L10n.of(context)!.addChatOrSubSpace, + actions: [ + AlertDialogAction( + key: AddRoomType.subspace, + label: L10n.of(context)!.createNewSpace, + ), + AlertDialogAction( + key: AddRoomType.chat, + label: L10n.of(context)!.createGroup, + ), + ], + ); + if (roomType == null) return; - ActiveFilter getActiveFilterByDestination(int? i) { - switch (i) { - case 1: - if (AppConfig.separateChatTypes) { - return ActiveFilter.groups; + final names = await showTextInputDialog( + context: context, + title: roomType == AddRoomType.subspace + ? L10n.of(context)!.createNewSpace + : L10n.of(context)!.createGroup, + textFields: [ + DialogTextField( + hintText: roomType == AddRoomType.subspace + ? L10n.of(context)!.spaceName + : L10n.of(context)!.groupName, + minLines: 1, + maxLines: 1, + maxLength: 64, + validator: (text) { + if (text == null || text.isEmpty) { + return L10n.of(context)!.pleaseChoose; + } + return null; + }, + ), + DialogTextField( + hintText: L10n.of(context)!.chatDescription, + minLines: 4, + maxLines: 8, + maxLength: 255, + ), + ], + okLabel: L10n.of(context)!.create, + cancelLabel: L10n.of(context)!.cancel, + ); + if (names == null) return; + final client = Matrix.of(context).client; + final result = await showFutureLoadingDialog( + context: context, + future: () async { + late final String roomId; + final activeSpace = client.getRoomById(activeSpaceId!)!; + await activeSpace.postLoad(); + + if (roomType == AddRoomType.subspace) { + roomId = await client.createSpace( + name: names.first, + topic: names.last.isEmpty ? null : names.last, + visibility: activeSpace.joinRules == JoinRules.public + ? sdk.Visibility.public + : sdk.Visibility.private, + ); + } else { + roomId = await client.createGroupChat( + groupName: names.first, + preset: activeSpace.joinRules == JoinRules.public + ? CreateRoomPreset.publicChat + : CreateRoomPreset.privateChat, + visibility: activeSpace.joinRules == JoinRules.public + ? sdk.Visibility.public + : sdk.Visibility.private, + initialState: names.length > 1 && names.last.isNotEmpty + ? [ + sdk.StateEvent( + type: sdk.EventTypes.RoomTopic, + content: {'topic': names.last}, + ), + ] + : null, + ); } - return ActiveFilter.spaces; - case 2: - return ActiveFilter.spaces; - case 0: - default: - if (AppConfig.separateChatTypes) { - return ActiveFilter.messages; - } - return ActiveFilter.allChats; + await activeSpace.setSpaceChild(roomId); + }, + ); + if (result.error != null) return; + } + + void onChatTap(Room room, BuildContext context) async { + if (room.isSpace) { + setActiveSpace(room.id); + return; + } + if (room.membership == Membership.invite) { + final inviterId = + room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId; + final inviteAction = await showModalActionSheet( + context: context, + message: room.isDirectChat + ? L10n.of(context)!.invitePrivateChat + : L10n.of(context)!.inviteGroupChat, + title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + actions: [ + SheetAction( + key: InviteActions.accept, + label: L10n.of(context)!.accept, + icon: Icons.check_outlined, + isDefaultAction: true, + ), + SheetAction( + key: InviteActions.decline, + label: L10n.of(context)!.decline, + icon: Icons.close_outlined, + isDestructiveAction: true, + ), + SheetAction( + key: InviteActions.block, + label: L10n.of(context)!.block, + icon: Icons.block_outlined, + isDestructiveAction: true, + ), + ], + ); + if (inviteAction == null) return; + if (inviteAction == InviteActions.block) { + context.go('/rooms/settings/security/ignorelist', extra: inviterId); + return; + } + if (inviteAction == InviteActions.decline) { + await showFutureLoadingDialog( + context: context, + future: room.leave, + ); + return; + } + final joinResult = await showFutureLoadingDialog( + context: context, + future: () async { + final waitForRoom = room.client.waitForRoomInSync( + room.id, + join: true, + ); + await room.join(); + await waitForRoom; + }, + ); + if (joinResult.error != null) return; } - } - void onDestinationSelected(int? i) { - setState(() { - selectedRoomIds.clear(); - activeFilter = getActiveFilterByDestination(i); - }); - } + if (room.membership == Membership.ban) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat), + ), + ); + return; + } - ActiveFilter activeFilter = AppConfig.separateChatTypes - ? ActiveFilter.messages - : ActiveFilter.allChats; + if (room.membership == Membership.leave) { + context.go('/rooms/archive/${room.id}'); + return; + } + + // Share content into this room + final shareContent = Matrix.of(context).shareContent; + if (shareContent != null) { + final shareFile = shareContent.tryGet('file'); + if (shareContent.tryGet('msgtype') == 'chat.fluffy.shared_file' && + shareFile != null) { + await showDialog( + context: context, + useRootNavigator: false, + builder: (c) => SendFileDialog( + files: [shareFile], + room: room, + ), + ); + Matrix.of(context).shareContent = null; + } else { + final consent = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context)!.forward, + message: L10n.of(context)!.forwardMessageTo( + room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + ), + okLabel: L10n.of(context)!.forward, + cancelLabel: L10n.of(context)!.cancel, + ); + if (consent == OkCancelResult.cancel) { + Matrix.of(context).shareContent = null; + return; + } + if (consent == OkCancelResult.ok) { + room.sendEvent(shareContent); + Matrix.of(context).shareContent = null; + } + } + } + + context.go('/rooms/${room.id}'); + } bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) { switch (activeFilter) { case ActiveFilter.allChats: - return (room) => !room.isSpace; + return (room) => true; case ActiveFilter.groups: return (room) => !room.isSpace && !room.isDirectChat; - case ActiveFilter.messages: - return (room) => !room.isSpace && room.isDirectChat; + case ActiveFilter.unread: + return (room) => room.isUnreadOrInvited; case ActiveFilter.spaces: - return (r) => r.isSpace; + return (room) => room.isSpace; } } @@ -331,15 +499,11 @@ class ChatListController extends State List get spaces => Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); - final selectedRoomIds = {}; - String? get activeChat => widget.activeChat; SelectMode get selectMode => Matrix.of(context).shareContent != null ? SelectMode.share - : selectedRoomIds.isEmpty - ? SelectMode.normal - : SelectMode.select; + : SelectMode.normal; void _processIncomingSharedFiles(List files) { if (files.isEmpty) return; @@ -448,80 +612,67 @@ class ChatListController extends State super.dispose(); } - void toggleSelection(String roomId) { - setState( - () => selectedRoomIds.contains(roomId) - ? selectedRoomIds.remove(roomId) - : selectedRoomIds.add(roomId), + void chatContextAction(Room room) async { + final action = await showModalActionSheet( + context: context, + title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + actions: [ + SheetAction( + key: ChatContextAction.markUnread, + icon: room.markedUnread + ? Icons.mark_as_unread + : Icons.mark_as_unread_outlined, + label: room.markedUnread + ? L10n.of(context)!.markAsRead + : L10n.of(context)!.unread, + ), + SheetAction( + key: ChatContextAction.favorite, + icon: room.isFavourite ? Icons.pin : Icons.pin_outlined, + label: room.isFavourite + ? L10n.of(context)!.unpin + : L10n.of(context)!.pin, + ), + SheetAction( + key: ChatContextAction.mute, + icon: room.pushRuleState == PushRuleState.notify + ? Icons.notifications_off_outlined + : Icons.notifications, + label: room.pushRuleState == PushRuleState.notify + ? L10n.of(context)!.muteChat + : L10n.of(context)!.unmuteChat, + ), + SheetAction( + isDestructiveAction: true, + key: ChatContextAction.leave, + icon: Icons.delete_outlined, + label: L10n.of(context)!.leave, + ), + ], ); - } - Future toggleUnread() async { + if (action == null) return; + if (!mounted) return; + await showFutureLoadingDialog( context: context, - future: () async { - final markUnread = anySelectedRoomNotMarkedUnread; - final client = Matrix.of(context).client; - for (final roomId in selectedRoomIds) { - final room = client.getRoomById(roomId)!; - if (room.markedUnread == markUnread) continue; - await client.getRoomById(roomId)!.markUnread(markUnread); + future: () { + switch (action) { + case ChatContextAction.favorite: + return room.setFavourite(!room.isFavourite); + case ChatContextAction.markUnread: + return room.markUnread(!room.markedUnread); + case ChatContextAction.mute: + return room.setPushRuleState( + room.pushRuleState == PushRuleState.notify + ? PushRuleState.mentionsOnly + : PushRuleState.notify, + ); + case ChatContextAction.leave: + return room.leave(); } }, ); - cancelAction(); - } - - Future toggleFavouriteRoom() async { - await showFutureLoadingDialog( - context: context, - future: () async { - final makeFavorite = anySelectedRoomNotFavorite; - final client = Matrix.of(context).client; - for (final roomId in selectedRoomIds) { - final room = client.getRoomById(roomId)!; - if (room.isFavourite == makeFavorite) continue; - await client.getRoomById(roomId)!.setFavourite(makeFavorite); - } - }, - ); - cancelAction(); - } - - Future toggleMuted() async { - await showFutureLoadingDialog( - context: context, - future: () async { - final newState = anySelectedRoomNotMuted - ? PushRuleState.mentionsOnly - : PushRuleState.notify; - final client = Matrix.of(context).client; - for (final roomId in selectedRoomIds) { - final room = client.getRoomById(roomId)!; - if (room.pushRuleState == newState) continue; - await client.getRoomById(roomId)!.setPushRuleState(newState); - } - }, - ); - cancelAction(); - } - - Future archiveAction() async { - final confirmed = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context)!.areYouSure, - okLabel: L10n.of(context)!.yes, - cancelLabel: L10n.of(context)!.cancel, - message: L10n.of(context)!.archiveRoomDescription, - ) == - OkCancelResult.ok; - if (!confirmed) return; - await showFutureLoadingDialog( - context: context, - future: () => _archiveSelectedRooms(), - ); - setState(() {}); } void dismissStatusList() async { @@ -568,76 +719,6 @@ class ChatListController extends State ); } - Future _archiveSelectedRooms() async { - final client = Matrix.of(context).client; - while (selectedRoomIds.isNotEmpty) { - final roomId = selectedRoomIds.first; - try { - await client.getRoomById(roomId)!.leave(); - } finally { - toggleSelection(roomId); - } - } - } - - Future addToSpace() async { - final selectedSpace = await showConfirmationDialog( - context: context, - title: L10n.of(context)!.addToSpace, - message: L10n.of(context)!.addToSpaceDescription, - fullyCapitalizedForMaterial: false, - actions: Matrix.of(context) - .client - .rooms - .where((r) => r.isSpace) - .map( - (space) => AlertDialogAction( - key: space.id, - label: space - .getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), - ), - ) - .toList(), - ); - if (selectedSpace == null) return; - final result = await showFutureLoadingDialog( - context: context, - future: () async { - final space = Matrix.of(context).client.getRoomById(selectedSpace)!; - if (space.canSendDefaultStates) { - for (final roomId in selectedRoomIds) { - await space.setSpaceChild(roomId); - } - } - }, - ); - if (result.error == null) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace), - ), - ); - } - - setState(() => selectedRoomIds.clear()); - } - - bool get anySelectedRoomNotMarkedUnread => selectedRoomIds.any( - (roomId) => - !Matrix.of(context).client.getRoomById(roomId)!.markedUnread, - ); - - bool get anySelectedRoomNotFavorite => selectedRoomIds.any( - (roomId) => !Matrix.of(context).client.getRoomById(roomId)!.isFavourite, - ); - - bool get anySelectedRoomNotMuted => selectedRoomIds.any( - (roomId) => - Matrix.of(context).client.getRoomById(roomId)!.pushRuleState == - PushRuleState.notify, - ); - bool waitForFirstSync = false; Future _waitForFirstSync() async { @@ -666,19 +747,20 @@ class ChatListController extends State void cancelAction() { if (selectMode == SelectMode.share) { setState(() => Matrix.of(context).shareContent = null); - } else { - setState(() => selectedRoomIds.clear()); } } + void setActiveFilter(ActiveFilter filter) { + setState(() { + activeFilter = filter; + }); + } + void setActiveClient(Client client) { context.go('/rooms'); setState(() { - activeFilter = AppConfig.separateChatTypes - ? ActiveFilter.messages - : ActiveFilter.allChats; - activeSpaceId = null; - selectedRoomIds.clear(); + activeFilter = ActiveFilter.allChats; + _activeSpaceId = null; Matrix.of(context).setActiveClient(client); }); _clientStream.add(client); @@ -687,7 +769,7 @@ class ChatListController extends State void setActiveBundle(String bundle) { context.go('/rooms'); setState(() { - selectedRoomIds.clear(); + _activeSpaceId = null; Matrix.of(context).activeBundle = bundle; if (!Matrix.of(context) .currentBundle! @@ -780,3 +862,18 @@ class ChatListController extends State } enum EditBundleAction { addToBundle, removeFromBundle } + +enum InviteActions { + accept, + decline, + block, +} + +enum AddRoomType { chat, subspace } + +enum ChatContextAction { + favorite, + markUnread, + mute, + leave, +} diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index eeb6bb65..a6526989 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -11,7 +10,6 @@ import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; -import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; @@ -29,6 +27,17 @@ class ChatListViewBody extends StatelessWidget { @override Widget build(BuildContext context) { + final activeSpace = controller.activeSpaceId; + if (activeSpace != null) { + return SpaceView( + spaceId: activeSpace, + onBack: controller.clearActiveSpace, + onChatTab: (room) => controller.onChatTap(room, context), + onChatContext: (room) => controller.chatContextAction(room), + activeChat: controller.activeChat, + toParentSpace: controller.setActiveSpace, + ); + } final publicRooms = controller.roomSearchResult?.chunk .where((room) => room.roomType != 'm.space') .toList(); @@ -43,224 +52,281 @@ class ChatListViewBody extends StatelessWidget { final subtitleColor = Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50); final filter = controller.searchController.text.toLowerCase(); - return PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation secondaryAnimation, - ) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.vertical, - fillColor: Theme.of(context).scaffoldBackgroundColor, - child: child, - ); - }, - child: StreamBuilder( - key: ValueKey( - client.userID.toString() + - controller.activeFilter.toString() + - controller.activeSpaceId.toString(), - ), - stream: client.onSync.stream - .where((s) => s.hasRoomUpdate) - .rateLimit(const Duration(seconds: 1)), - builder: (context, _) { - if (controller.activeFilter == ActiveFilter.spaces) { - return SpaceView( - controller, - scrollController: controller.scrollController, - key: Key(controller.activeSpaceId ?? 'Spaces'), - ); + return StreamBuilder( + key: ValueKey( + client.userID.toString(), + ), + stream: client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, _) { + final rooms = controller.filteredRooms; + + final spaces = rooms.where((r) => r.isSpace); + final spaceDelegateCandidates = {}; + for (final space in spaces) { + spaceDelegateCandidates[space.id] = space; + for (final spaceChild in space.spaceChildren) { + final roomId = spaceChild.roomId; + if (roomId == null) continue; + spaceDelegateCandidates[roomId] = space; } - final rooms = controller.filteredRooms; - return SafeArea( - child: CustomScrollView( - controller: controller.scrollController, - slivers: [ - ChatListHeader(controller: controller), - SliverList( - delegate: SliverChildListDelegate( - [ - if (controller.isSearchMode) ...[ - SearchTitle( - title: L10n.of(context)!.publicRooms, - icon: const Icon(Icons.explore_outlined), + } + final spaceDelegates = {}; + + return SafeArea( + child: CustomScrollView( + controller: controller.scrollController, + slivers: [ + ChatListHeader(controller: controller), + SliverList( + delegate: SliverChildListDelegate( + [ + if (controller.isSearchMode) ...[ + SearchTitle( + title: L10n.of(context)!.publicRooms, + icon: const Icon(Icons.explore_outlined), + ), + PublicRoomsHorizontalList(publicRooms: publicRooms), + SearchTitle( + title: L10n.of(context)!.publicSpaces, + icon: const Icon(Icons.workspaces_outlined), + ), + PublicRoomsHorizontalList(publicRooms: publicSpaces), + SearchTitle( + title: L10n.of(context)!.users, + icon: const Icon(Icons.group_outlined), + ), + AnimatedContainer( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + height: userSearchResult == null || + userSearchResult.results.isEmpty + ? 0 + : 106, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: userSearchResult == null + ? null + : ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: userSearchResult.results.length, + itemBuilder: (context, i) => _SearchItem( + title: + userSearchResult.results[i].displayName ?? + userSearchResult + .results[i].userId.localpart ?? + L10n.of(context)!.unknownDevice, + avatar: userSearchResult.results[i].avatarUrl, + onPressed: () => showAdaptiveBottomSheet( + context: context, + builder: (c) => UserBottomSheet( + profile: userSearchResult.results[i], + outerContext: context, + ), + ), + ), + ), + ), + ], + if (!controller.isSearchMode && AppConfig.showPresences) + GestureDetector( + onLongPress: () => controller.dismissStatusList(), + child: StatusMessageList( + onStatusEdit: controller.setStatus, ), - PublicRoomsHorizontalList(publicRooms: publicRooms), - SearchTitle( - title: L10n.of(context)!.publicSpaces, - icon: const Icon(Icons.workspaces_outlined), + ), + const ConnectionStatusHeader(), + AnimatedContainer( + height: controller.isTorBrowser ? 64 : 0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Material( + color: Theme.of(context).colorScheme.surface, + child: ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(L10n.of(context)!.dehydrateTor), + subtitle: Text(L10n.of(context)!.dehydrateTorLong), + trailing: const Icon(Icons.chevron_right_outlined), + onTap: controller.dehydrate, ), - PublicRoomsHorizontalList(publicRooms: publicSpaces), - SearchTitle( - title: L10n.of(context)!.users, - icon: const Icon(Icons.group_outlined), - ), - AnimatedContainer( - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - height: userSearchResult == null || - userSearchResult.results.isEmpty - ? 0 - : 106, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: userSearchResult == null - ? null - : ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: userSearchResult.results.length, - itemBuilder: (context, i) => _SearchItem( - title: userSearchResult - .results[i].displayName ?? - userSearchResult - .results[i].userId.localpart ?? - L10n.of(context)!.unknownDevice, - avatar: - userSearchResult.results[i].avatarUrl, - onPressed: () => showAdaptiveBottomSheet( - context: context, - builder: (c) => UserBottomSheet( - profile: userSearchResult.results[i], - outerContext: context, + ), + ), + if (client.rooms.isNotEmpty && !controller.isSearchMode) + SizedBox( + height: 44, + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 6, + ), + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: ActiveFilter.values + .map( + (filter) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4), + 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.of(context) + .colorScheme + .primary + : Theme.of(context) + .colorScheme + .secondaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + alignment: Alignment.center, + child: Text( + filter.toLocalizedString(context), + style: TextStyle( + fontWeight: + filter == controller.activeFilter + ? FontWeight.bold + : FontWeight.normal, + color: + filter == controller.activeFilter + ? Theme.of(context) + .colorScheme + .onPrimary + : Theme.of(context) + .colorScheme + .onSecondaryContainer, + ), ), ), ), ), - ), - ], - if (!controller.isSearchMode && - controller.activeFilter != ActiveFilter.groups && - AppConfig.showPresences) - GestureDetector( - onLongPress: () => controller.dismissStatusList(), - child: StatusMessageList( - onStatusEdit: controller.setStatus, - ), - ), - const ConnectionStatusHeader(), - AnimatedContainer( - height: controller.isTorBrowser ? 64 : 0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: Material( - color: Theme.of(context).colorScheme.surface, - child: ListTile( - leading: const Icon(Icons.vpn_key), - title: Text(L10n.of(context)!.dehydrateTor), - subtitle: Text(L10n.of(context)!.dehydrateTorLong), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: controller.dehydrate, - ), + ) + .toList(), ), ), - if (controller.isSearchMode) - SearchTitle( - title: L10n.of(context)!.chats, - icon: const Icon(Icons.forum_outlined), + if (controller.isSearchMode) + SearchTitle( + title: L10n.of(context)!.chats, + icon: const Icon(Icons.forum_outlined), + ), + if (client.prevBatch != null && + rooms.isEmpty && + !controller.isSearchMode) ...[ + Padding( + padding: const EdgeInsets.all(32.0), + child: Icon( + CupertinoIcons.chat_bubble_2, + size: 128, + color: Theme.of(context).colorScheme.secondary, ), - if (client.prevBatch != null && - rooms.isEmpty && - !controller.isSearchMode) ...[ - Padding( - padding: const EdgeInsets.all(32.0), - child: Icon( - CupertinoIcons.chat_bubble_2, - size: 128, - color: - Theme.of(context).colorScheme.onInverseSurface, + ), + ], + ], + ), + ), + if (client.prevBatch == null) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Opacity( + opacity: (dummyChatCount - i) / dummyChatCount, + child: ListTile( + leading: CircleAvatar( + backgroundColor: titleColor, + child: CircularProgressIndicator( + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyLarge!.color, ), ), - ], - ], + title: Row( + children: [ + Expanded( + child: Container( + height: 14, + decoration: BoxDecoration( + color: titleColor, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + const SizedBox(width: 36), + Container( + height: 14, + width: 14, + decoration: BoxDecoration( + color: subtitleColor, + borderRadius: BorderRadius.circular(14), + ), + ), + const SizedBox(width: 12), + Container( + height: 14, + width: 14, + decoration: BoxDecoration( + color: subtitleColor, + borderRadius: BorderRadius.circular(14), + ), + ), + ], + ), + subtitle: Container( + decoration: BoxDecoration( + color: subtitleColor, + borderRadius: BorderRadius.circular(3), + ), + height: 12, + margin: const EdgeInsets.only(right: 22), + ), + ), + ), + childCount: dummyChatCount, ), ), - if (client.prevBatch == null) - SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Opacity( - opacity: (dummyChatCount - i) / dummyChatCount, - child: ListTile( - leading: CircleAvatar( - backgroundColor: titleColor, - child: CircularProgressIndicator( - strokeWidth: 1, - color: - Theme.of(context).textTheme.bodyLarge!.color, - ), - ), - title: Row( - children: [ - Expanded( - child: Container( - height: 14, - decoration: BoxDecoration( - color: titleColor, - borderRadius: BorderRadius.circular(3), - ), - ), - ), - const SizedBox(width: 36), - Container( - height: 14, - width: 14, - decoration: BoxDecoration( - color: subtitleColor, - borderRadius: BorderRadius.circular(14), - ), - ), - const SizedBox(width: 12), - Container( - height: 14, - width: 14, - decoration: BoxDecoration( - color: subtitleColor, - borderRadius: BorderRadius.circular(14), - ), - ), - ], - ), - subtitle: Container( - decoration: BoxDecoration( - color: subtitleColor, - borderRadius: BorderRadius.circular(3), - ), - height: 12, - margin: const EdgeInsets.only(right: 22), - ), - ), - ), - childCount: dummyChatCount, - ), - ), - if (client.prevBatch != null) - SliverList.builder( - itemCount: rooms.length, - itemBuilder: (BuildContext context, int i) { - return ChatListItem( - rooms[i], - key: Key('chat_list_item_${rooms[i].id}'), - filter: filter, - selected: - controller.selectedRoomIds.contains(rooms[i].id), - onTap: controller.selectMode == SelectMode.select - ? () => controller.toggleSelection(rooms[i].id) - : () => onChatTap(rooms[i], context), - onLongPress: () => - controller.toggleSelection(rooms[i].id), - activeChat: controller.activeChat == rooms[i].id, - ); - }, - ), - ], - ), - ); - }, - ), + if (client.prevBatch != null) + SliverList.builder( + itemCount: rooms.length, + itemBuilder: (BuildContext context, int i) { + var room = rooms[i]; + if (controller.activeFilter != ActiveFilter.groups) { + final parent = room.isSpace + ? room + : spaceDelegateCandidates[room.id]; + if (parent != null) { + if (spaceDelegates.contains(parent.id)) { + return const SizedBox.shrink(); + } + spaceDelegates.add(parent.id); + room = parent; + } + } + + return ChatListItem( + room, + lastEventRoom: rooms[i], + key: Key('chat_list_item_${room.id}'), + filter: filter, + onTap: () => controller.onChatTap(room, context), + onLongPress: () => controller.chatContextAction(room), + activeChat: controller.activeChat == room.id, + ); + }, + ), + ], + ), + ); + }, ); } } diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index 5e76e780..d60cc9ba 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -43,88 +43,77 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { L10n.of(context)!.share, key: const ValueKey(SelectMode.share), ) - : selectMode == SelectMode.select - ? Text( - controller.selectedRoomIds.length.toString(), - key: const ValueKey(SelectMode.select), - ) - : TextField( - controller: controller.searchController, - focusNode: controller.searchFocusNode, - textInputAction: TextInputAction.search, - onChanged: (text) => controller.onSearchEnter( - text, - globalSearch: globalSearch, - ), - decoration: InputDecoration( - fillColor: Theme.of(context).colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - contentPadding: EdgeInsets.zero, - hintText: L10n.of(context)!.searchChatsRooms, - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: controller.isSearchMode - ? IconButton( - tooltip: L10n.of(context)!.cancel, - icon: const Icon(Icons.close_outlined), - onPressed: controller.cancelSearch, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ) - : IconButton( - onPressed: controller.startSearch, - icon: Icon( - Icons.search_outlined, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - suffixIcon: controller.isSearchMode && globalSearch - ? controller.isSearching - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 12, - ), - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ), - ) - : TextButton.icon( - onPressed: controller.setServer, - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), - ), - textStyle: const TextStyle(fontSize: 12), - ), - icon: const Icon(Icons.edit_outlined, size: 16), - label: Text( - controller.searchServer ?? - Matrix.of(context) - .client - .homeserver! - .host, - maxLines: 2, - ), - ) - : SizedBox( - width: 0, - child: ClientChooserButton(controller), - ), - ), + : TextField( + controller: controller.searchController, + focusNode: controller.searchFocusNode, + textInputAction: TextInputAction.search, + onChanged: (text) => controller.onSearchEnter( + text, + globalSearch: globalSearch, + ), + decoration: InputDecoration( + fillColor: Theme.of(context).colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), ), + contentPadding: EdgeInsets.zero, + hintText: L10n.of(context)!.searchChatsRooms, + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixIcon: controller.isSearchMode + ? IconButton( + tooltip: L10n.of(context)!.cancel, + icon: const Icon(Icons.close_outlined), + onPressed: controller.cancelSearch, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ) + : IconButton( + onPressed: controller.startSearch, + icon: Icon( + Icons.search_outlined, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + suffixIcon: controller.isSearchMode && globalSearch + ? controller.isSearching + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 12, + ), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ) + : TextButton.icon( + onPressed: controller.setServer, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(99), + ), + textStyle: const TextStyle(fontSize: 12), + ), + icon: const Icon(Icons.edit_outlined, size: 16), + label: Text( + controller.searchServer ?? + Matrix.of(context).client.homeserver!.host, + maxLines: 2, + ), + ) + : SizedBox( + width: 0, + child: ClientChooserButton(controller), + ), + ), + ), actions: selectMode == SelectMode.share ? [ Padding( @@ -135,48 +124,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { child: ClientChooserButton(controller), ), ] - : selectMode == SelectMode.select - ? [ - if (controller.spaces.isNotEmpty) - IconButton( - tooltip: L10n.of(context)!.addToSpace, - icon: const Icon(Icons.workspaces_outlined), - onPressed: controller.addToSpace, - ), - IconButton( - tooltip: L10n.of(context)!.toggleUnread, - icon: Icon( - controller.anySelectedRoomNotMarkedUnread - ? Icons.mark_chat_unread_outlined - : Icons.mark_chat_read_outlined, - ), - onPressed: controller.toggleUnread, - ), - IconButton( - tooltip: L10n.of(context)!.toggleFavorite, - icon: Icon( - controller.anySelectedRoomNotFavorite - ? Icons.push_pin - : Icons.push_pin_outlined, - ), - onPressed: controller.toggleFavouriteRoom, - ), - IconButton( - icon: Icon( - controller.anySelectedRoomNotMuted - ? Icons.notifications_off_outlined - : Icons.notifications_outlined, - ), - tooltip: L10n.of(context)!.toggleMuted, - onPressed: controller.toggleMuted, - ), - IconButton( - icon: const Icon(Icons.delete_outlined), - tooltip: L10n.of(context)!.archive, - onPressed: controller.archiveAction, - ), - ] - : null, + : null, ); } diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index 4ac02e62..7bf5f6b5 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -17,8 +17,8 @@ enum ArchivedRoomAction { delete, rejoin } class ChatListItem extends StatelessWidget { final Room room; + final Room? lastEventRoom; final bool activeChat; - final bool selected; final void Function()? onLongPress; final void Function()? onForget; final void Function() onTap; @@ -27,11 +27,11 @@ class ChatListItem extends StatelessWidget { const ChatListItem( this.room, { this.activeChat = false, - this.selected = false, required this.onTap, this.onLongPress, this.onForget, this.filter, + this.lastEventRoom, super.key, }); @@ -64,24 +64,23 @@ class ChatListItem extends StatelessWidget { @override Widget build(BuildContext context) { final isMuted = room.pushRuleState != PushRuleState.notify; - final typingText = room.getLocalizedTypingText(context); - final lastEvent = room.lastEvent; + final lastEventRoom = this.lastEventRoom ?? room; + final typingText = lastEventRoom.getLocalizedTypingText(context); + final lastEvent = lastEventRoom.lastEvent; final ownMessage = lastEvent?.senderId == room.client.userID; - final unread = room.isUnread || room.membership == Membership.invite; + final unread = + lastEventRoom.isUnread || lastEventRoom.membership == Membership.invite; final theme = Theme.of(context); final directChatMatrixId = room.directChatMatrixID; final isDirectChat = directChatMatrixId != null; - final unreadBubbleSize = unread || room.hasNewMessages - ? room.notificationCount > 0 + final unreadBubbleSize = unread || lastEventRoom.hasNewMessages + ? lastEventRoom.notificationCount > 0 ? 20.0 : 14.0 : 0.0; - final hasNotifications = room.notificationCount > 0; - final backgroundColor = selected - ? theme.colorScheme.primaryContainer - : activeChat - ? theme.colorScheme.secondaryContainer - : null; + final hasNotifications = lastEventRoom.notificationCount > 0; + final backgroundColor = + activeChat ? theme.colorScheme.secondaryContainer : null; final displayname = room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)!), ); @@ -119,6 +118,9 @@ class ChatListItem extends StatelessWidget { curve: FluffyThemes.animationCurve, scale: hovered ? 1.1 : 1.0, child: Avatar( + borderRadius: room.isSpace + ? BorderRadius.circular(AppConfig.borderRadius / 3) + : null, mxContent: room.avatar, name: displayname, presenceUserId: directChatMatrixId, @@ -133,14 +135,12 @@ class ChatListItem extends StatelessWidget { child: AnimatedScale( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, - scale: (hovered || selected) ? 1.0 : 0.0, + scale: (hovered) ? 1.0 : 0.0, child: Material( color: backgroundColor, borderRadius: BorderRadius.circular(16), - child: Icon( - selected - ? Icons.check_circle - : Icons.check_circle_outlined, + child: const Icon( + Icons.check_circle_outlined, size: 18, ), ), @@ -180,7 +180,9 @@ class ChatListItem extends StatelessWidget { color: theme.colorScheme.primary, ), ), - if (lastEvent != null && room.membership != Membership.invite) + if (!room.isSpace && + lastEvent != null && + room.membership != Membership.invite) Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( @@ -193,11 +195,30 @@ class ChatListItem extends StatelessWidget { ), ), ), + if (room.isSpace) + const Icon( + Icons.arrow_circle_right_outlined, + size: 18, + ), ], ), subtitle: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ + if (room.isSpace) ...[ + room.id != lastEventRoom.id && + lastEventRoom.isUnreadOrInvited + ? Avatar( + mxContent: lastEventRoom.avatar, + name: lastEventRoom.name, + size: 18, + ) + : const Icon( + Icons.workspaces_outlined, + size: 18, + ), + const SizedBox(width: 4), + ], if (typingText.isEmpty && ownMessage && room.lastEvent!.status.isSending) ...[ @@ -222,62 +243,71 @@ class ChatListItem extends StatelessWidget { ), ), Expanded( - child: typingText.isNotEmpty + child: room.isSpace && !lastEventRoom.isUnreadOrInvited ? Text( - typingText, - style: TextStyle( - color: theme.colorScheme.primary, + L10n.of(context)!.countChatsAndCountParticipants( + room.spaceChildren.length.toString(), + (room.summary.mJoinedMemberCount ?? 1).toString(), ), - maxLines: 1, - softWrap: false, ) - : FutureBuilder( - key: ValueKey( - '${lastEvent?.eventId}_${lastEvent?.type}', - ), - future: needLastEventSender - ? lastEvent.calcLocalizedBody( - MatrixLocals(L10n.of(context)!), - hideReply: true, - hideEdit: true, - plaintextBody: true, - removeMarkdown: true, - withSenderNamePrefix: !isDirectChat || - directChatMatrixId != - room.lastEvent?.senderId, - ) - : null, - initialData: lastEvent?.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)!), - hideReply: true, - hideEdit: true, - plaintextBody: true, - removeMarkdown: true, - withSenderNamePrefix: !isDirectChat || - directChatMatrixId != - room.lastEvent?.senderId, - ), - builder: (context, snapshot) => Text( - room.membership == Membership.invite - ? isDirectChat - ? L10n.of(context)!.invitePrivateChat - : L10n.of(context)!.inviteGroupChat - : snapshot.data ?? - L10n.of(context)!.emptyChat, - softWrap: false, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: unread || room.hasNewMessages - ? FontWeight.bold - : null, - color: theme.colorScheme.onSurfaceVariant, - decoration: room.lastEvent?.redacted == true - ? TextDecoration.lineThrough + : typingText.isNotEmpty + ? Text( + typingText, + style: TextStyle( + color: theme.colorScheme.primary, + ), + maxLines: 1, + softWrap: false, + ) + : FutureBuilder( + key: ValueKey( + '${lastEvent?.eventId}_${lastEvent?.type}', + ), + future: needLastEventSender + ? lastEvent.calcLocalizedBody( + MatrixLocals(L10n.of(context)!), + hideReply: true, + hideEdit: true, + plaintextBody: true, + removeMarkdown: true, + withSenderNamePrefix: (!isDirectChat || + directChatMatrixId != + room.lastEvent?.senderId), + ) : null, + initialData: + lastEvent?.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)!), + hideReply: true, + hideEdit: true, + plaintextBody: true, + removeMarkdown: true, + withSenderNamePrefix: (!isDirectChat || + directChatMatrixId != + room.lastEvent?.senderId), + ), + builder: (context, snapshot) => Text( + room.membership == Membership.invite + ? isDirectChat + ? L10n.of(context)!.invitePrivateChat + : L10n.of(context)!.inviteGroupChat + : snapshot.data ?? + L10n.of(context)!.emptyChat, + softWrap: false, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: + unread || lastEventRoom.hasNewMessages + ? FontWeight.bold + : null, + color: theme.colorScheme.onSurfaceVariant, + decoration: room.lastEvent?.redacted == true + ? TextDecoration.lineThrough + : null, + ), + ), ), - ), - ), ), const SizedBox(width: 8), AnimatedContainer( @@ -288,7 +318,9 @@ class ChatListItem extends StatelessWidget { width: !hasNotifications && !unread && !room.hasNewMessages ? 0 : (unreadBubbleSize - 9) * - room.notificationCount.toString().length + + lastEventRoom.notificationCount + .toString() + .length + 9, decoration: BoxDecoration( color: room.highlightCount > 0 || @@ -303,7 +335,7 @@ class ChatListItem extends StatelessWidget { child: Center( child: hasNotifications ? Text( - room.notificationCount.toString(), + lastEventRoom.notificationCount.toString(), style: TextStyle( color: room.highlightCount > 0 ? Colors.white diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 160db9b8..79846469 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -1,87 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:badges/badges.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/chat_list/navi_rail_item.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/unread_rooms_badge.dart'; import '../../widgets/matrix.dart'; import 'chat_list_body.dart'; -import 'start_chat_fab.dart'; class ChatListView extends StatelessWidget { final ChatListController controller; const ChatListView(this.controller, {super.key}); - List getNavigationDestinations(BuildContext context) { - final badgePosition = BadgePosition.topEnd(top: -12, end: -8); - return [ - if (AppConfig.separateChatTypes) ...[ - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.messages), - child: const Icon(Icons.chat_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.messages), - child: const Icon(Icons.chat), - ), - label: L10n.of(context)!.messages, - ), - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups), - child: const Icon(Icons.group_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups), - child: const Icon(Icons.group), - ), - label: L10n.of(context)!.groups, - ), - ] else - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.allChats), - child: const Icon(Icons.chat_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.allChats), - child: const Icon(Icons.chat), - ), - label: L10n.of(context)!.chats, - ), - if (controller.spaces.isNotEmpty) - const NavigationDestination( - icon: Icon(Icons.workspaces_outlined), - selectedIcon: Icon(Icons.workspaces), - label: 'Spaces', - ), - ]; - } - @override Widget build(BuildContext context) { - final client = Matrix.of(context).client; return StreamBuilder( stream: Matrix.of(context).onShareContentChanged.stream, builder: (_, __) { @@ -89,10 +23,7 @@ class ChatListView extends StatelessWidget { return PopScope( canPop: controller.selectMode == SelectMode.normal && !controller.isSearchMode && - controller.activeFilter == - (AppConfig.separateChatTypes - ? ActiveFilter.messages - : ActiveFilter.allChats), + controller.activeFilter == ActiveFilter.allChats, onPopInvoked: (pop) async { if (pop) return; final selMode = controller.selectMode; @@ -104,122 +35,33 @@ class ChatListView extends StatelessWidget { controller.cancelAction(); return; } - if (controller.activeFilter != - (AppConfig.separateChatTypes - ? ActiveFilter.messages - : ActiveFilter.allChats)) { - controller - .onDestinationSelected(AppConfig.separateChatTypes ? 1 : 0); - return; - } }, - child: Row( - children: [ - if (FluffyThemes.isColumnMode(context) && - controller.widget.displayNavigationRail) ...[ - Builder( - builder: (context) { - final allSpaces = - client.rooms.where((room) => room.isSpace); - final rootSpaces = allSpaces - .where( - (space) => !allSpaces.any( - (parentSpace) => parentSpace.spaceChildren - .any((child) => child.roomId == space.id), - ), - ) - .toList(); - final destinations = getNavigationDestinations(context); - - return SizedBox( - width: FluffyThemes.navRailWidth, - child: ListView.builder( - scrollDirection: Axis.vertical, - itemCount: rootSpaces.length + destinations.length, - itemBuilder: (context, i) { - if (i < destinations.length) { - return NaviRailItem( - isSelected: i == controller.selectedIndex, - onTap: () => controller.onDestinationSelected(i), - icon: destinations[i].icon, - selectedIcon: destinations[i].selectedIcon, - toolTip: destinations[i].label, - ); - } - i -= destinations.length; - final isSelected = - controller.activeFilter == ActiveFilter.spaces && - rootSpaces[i].id == controller.activeSpaceId; - return NaviRailItem( - toolTip: rootSpaces[i].getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), + child: GestureDetector( + onTap: FocusManager.instance.primaryFocus?.unfocus, + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + child: Scaffold( + body: ChatListViewBody(controller), + floatingActionButton: KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyN, + }, + onKeysPressed: () => context.go('/rooms/newprivatechat'), + helpLabel: L10n.of(context)!.newChat, + child: + selectMode == SelectMode.normal && !controller.isSearchMode + ? FloatingActionButton.extended( + onPressed: controller.addChatAction, + icon: const Icon(Icons.add_outlined), + label: Text( + L10n.of(context)!.chat, + overflow: TextOverflow.fade, ), - isSelected: isSelected, - onTap: () => - controller.setActiveSpace(rootSpaces[i].id), - icon: Avatar( - mxContent: rootSpaces[i].avatar, - name: rootSpaces[i].getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ), - size: 32, - ), - ); - }, - ), - ); - }, - ), - Container( - color: Theme.of(context).dividerColor, - width: 1, - ), - ], - Expanded( - child: GestureDetector( - onTap: FocusManager.instance.primaryFocus?.unfocus, - excludeFromSemantics: true, - behavior: HitTestBehavior.translucent, - child: Scaffold( - body: ChatListViewBody(controller), - bottomNavigationBar: controller.displayNavigationBar - ? NavigationBar( - elevation: 4, - labelBehavior: - NavigationDestinationLabelBehavior.alwaysShow, - shadowColor: - Theme.of(context).colorScheme.onSurface, - backgroundColor: - Theme.of(context).colorScheme.surface, - surfaceTintColor: - Theme.of(context).colorScheme.surface, - selectedIndex: controller.selectedIndex, - onDestinationSelected: - controller.onDestinationSelected, - destinations: getNavigationDestinations(context), ) - : null, - floatingActionButton: KeyBoardShortcuts( - keysToPress: { - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.keyN, - }, - onKeysPressed: () => context.go('/rooms/newprivatechat'), - helpLabel: L10n.of(context)!.newChat, - child: selectMode == SelectMode.normal && - !controller.isSearchMode - ? StartChatFloatingActionButton( - activeFilter: controller.activeFilter, - roomsIsEmpty: false, - scrolledToTop: controller.scrolledToTop, - createNewSpace: controller.createNewSpace, - ) - : const SizedBox.shrink(), - ), - ), - ), + : const SizedBox.shrink(), ), - ], + ), ), ); }, diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index a984b437..6459de1e 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -5,26 +5,32 @@ import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; -import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; -import '../../utils/localized_exception_extension.dart'; -import '../../widgets/matrix.dart'; -import 'chat_list_header.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class SpaceView extends StatefulWidget { - final ChatListController controller; - final ScrollController scrollController; - const SpaceView( - this.controller, { + final String spaceId; + final void Function() onBack; + final void Function(String spaceId) toParentSpace; + final void Function(Room room) onChatTab; + final void Function(Room room) onChatContext; + final String? activeChat; + + const SpaceView({ + required this.spaceId, + required this.onBack, + required this.onChatTab, + required this.activeChat, + required this.toParentSpace, + required this.onChatContext, super.key, - required this.scrollController, }); @override @@ -32,543 +38,449 @@ class SpaceView extends StatefulWidget { } class _SpaceViewState extends State { - static final Map _lastResponse = {}; - - String? prevBatch; - Object? error; - bool loading = false; + final List _discoveredChildren = []; + final TextEditingController _filterController = TextEditingController(); + String? _nextBatch; + bool _noMoreRooms = false; + bool _isLoading = false; @override void initState() { - loadHierarchy(); + _loadHierarchy(); super.initState(); } - void _refresh() { - _lastResponse.remove(widget.controller.activeSpaceId); - loadHierarchy(); - } - - Future loadHierarchy([String? prevBatch]) async { - final activeSpaceId = widget.controller.activeSpaceId; - if (activeSpaceId == null) return null; - final client = Matrix.of(context).client; - - final activeSpace = client.getRoomById(activeSpaceId); - await activeSpace?.postLoad(); + void _loadHierarchy() async { + final room = Matrix.of(context).client.getRoomById(widget.spaceId); + if (room == null) return; setState(() { - error = null; - loading = true; + _isLoading = true; }); try { - final response = await client.getSpaceHierarchy( - activeSpaceId, - maxDepth: 1, - from: prevBatch, + final hierarchy = await room.client.getSpaceHierarchy( + widget.spaceId, + suggestedOnly: false, + maxDepth: 2, + from: _nextBatch, ); - - if (prevBatch != null) { - response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []); - } + if (!mounted) return; setState(() { - _lastResponse[activeSpaceId] = response; - }); - return _lastResponse[activeSpaceId]!; - } catch (e) { - setState(() { - error = e; - }); - rethrow; - } finally { - setState(() { - loading = false; - }); - } - } - - void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { - final client = Matrix.of(context).client; - final space = client.getRoomById(widget.controller.activeSpaceId!); - if (client.getRoomById(spaceChild.roomId) == null) { - final result = await showFutureLoadingDialog( - context: context, - future: () async { - await client.joinRoom( - spaceChild.roomId, - serverName: space?.spaceChildren - .firstWhereOrNull( - (child) => child.roomId == spaceChild.roomId, - ) - ?.via, - ); - if (client.getRoomById(spaceChild.roomId) == null) { - // Wait for room actually appears in sync - await client.waitForRoomInSync(spaceChild.roomId, join: true); - } - }, - ); - if (result.error != null) return; - _refresh(); - } - if (spaceChild.roomType == 'm.space') { - if (spaceChild.roomId == widget.controller.activeSpaceId) { - context.go('/rooms/${spaceChild.roomId}'); - } else { - widget.controller.setActiveSpace(spaceChild.roomId); - } - return; - } - context.go('/rooms/${spaceChild.roomId}'); - } - - void _onSpaceChildContextMenu([ - SpaceRoomsChunk? spaceChild, - Room? room, - ]) async { - final client = Matrix.of(context).client; - final activeSpaceId = widget.controller.activeSpaceId; - final activeSpace = - activeSpaceId == null ? null : client.getRoomById(activeSpaceId); - final action = await showModalActionSheet( - context: context, - title: spaceChild?.name ?? - room?.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ), - message: spaceChild?.topic ?? room?.topic, - actions: [ - if (room == null) - SheetAction( - key: SpaceChildContextAction.join, - label: L10n.of(context)!.joinRoom, - icon: Icons.send_outlined, - ), - if (spaceChild != null && - (activeSpace?.canChangeStateEvent(EventTypes.SpaceChild) ?? false)) - SheetAction( - key: SpaceChildContextAction.removeFromSpace, - label: L10n.of(context)!.removeFromSpace, - icon: Icons.delete_sweep_outlined, - ), - if (room != null) - SheetAction( - key: SpaceChildContextAction.leave, - label: L10n.of(context)!.leave, - icon: Icons.delete_outlined, - isDestructiveAction: true, - ), - ], - ); - if (action == null) return; - - switch (action) { - case SpaceChildContextAction.join: - _onJoinSpaceChild(spaceChild!); - break; - case SpaceChildContextAction.leave: - await showFutureLoadingDialog( - context: context, - future: room!.leave, + _nextBatch = hierarchy.nextBatch; + if (hierarchy.nextBatch == null) { + _noMoreRooms = true; + } + _discoveredChildren.addAll( + hierarchy.rooms + .where((c) => room.client.getRoomById(c.roomId) == null), ); - break; - case SpaceChildContextAction.removeFromSpace: - await showFutureLoadingDialog( - context: context, - future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId), - ); - break; + _isLoading = false; + }); + } catch (e, s) { + Logs().w('Unable to load hierarchy', e, s); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); + setState(() { + _isLoading = false; + }); } } - void _addChatOrSubSpace() async { - final roomType = await showConfirmationDialog( - context: context, - title: L10n.of(context)!.addChatOrSubSpace, - actions: [ - AlertDialogAction( - key: AddRoomType.subspace, - label: L10n.of(context)!.createNewSpace, - ), - AlertDialogAction( - key: AddRoomType.chat, - label: L10n.of(context)!.createGroup, - ), - ], - ); - if (roomType == null) return; + void _joinChildRoom(SpaceRoomsChunk item) async { + final client = Matrix.of(context).client; + final space = client.getRoomById(widget.spaceId); - final names = await showTextInputDialog( + final consent = await showOkCancelAlertDialog( context: context, - title: roomType == AddRoomType.subspace - ? L10n.of(context)!.createNewSpace - : L10n.of(context)!.createGroup, - textFields: [ - DialogTextField( - hintText: roomType == AddRoomType.subspace - ? L10n.of(context)!.spaceName - : L10n.of(context)!.groupName, - minLines: 1, - maxLines: 1, - maxLength: 64, - validator: (text) { - if (text == null || text.isEmpty) { - return L10n.of(context)!.pleaseChoose; - } - return null; - }, - ), - DialogTextField( - hintText: L10n.of(context)!.chatDescription, - minLines: 4, - maxLines: 8, - maxLength: 255, - ), - ], - okLabel: L10n.of(context)!.create, + title: item.name ?? item.canonicalAlias ?? L10n.of(context)!.emptyChat, + message: item.topic, + okLabel: L10n.of(context)!.joinRoom, cancelLabel: L10n.of(context)!.cancel, ); - if (names == null) return; - final client = Matrix.of(context).client; - final result = await showFutureLoadingDialog( + if (consent != OkCancelResult.ok) return; + if (!mounted) return; + + await showFutureLoadingDialog( context: context, future: () async { - late final String roomId; - final activeSpace = client.getRoomById( - widget.controller.activeSpaceId!, - )!; - - if (roomType == AddRoomType.subspace) { - roomId = await client.createSpace( - name: names.first, - topic: names.last.isEmpty ? null : names.last, - visibility: activeSpace.joinRules == JoinRules.public - ? sdk.Visibility.public - : sdk.Visibility.private, - ); - } else { - roomId = await client.createGroupChat( - groupName: names.first, - initialState: names.length > 1 && names.last.isNotEmpty - ? [ - sdk.StateEvent( - type: sdk.EventTypes.RoomTopic, - content: {'topic': names.last}, - ), - ] - : null, - ); + await client.joinRoom( + item.roomId, + serverName: space?.spaceChildren + .firstWhereOrNull( + (child) => child.roomId == item.roomId, + ) + ?.via, + ); + if (client.getRoomById(item.roomId) == null) { + // Wait for room actually appears in sync + await client.waitForRoomInSync(item.roomId, join: true); } - await activeSpace.setSpaceChild(roomId); }, ); - if (result.error != null) return; - _refresh(); + if (!mounted) return; + + setState(() { + _discoveredChildren.remove(item); + }); + } + + void _onSpaceAction(SpaceActions action) async { + final space = Matrix.of(context).client.getRoomById(widget.spaceId); + + switch (action) { + case SpaceActions.settings: + await space?.postLoad(); + context.push('/rooms/${widget.spaceId}/details'); + break; + case SpaceActions.invite: + await space?.postLoad(); + context.push('/rooms/${widget.spaceId}/invite'); + break; + case SpaceActions.leave: + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context)!.areYouSure, + okLabel: L10n.of(context)!.ok, + cancelLabel: L10n.of(context)!.cancel, + message: L10n.of(context)!.archiveRoomDescription, + ); + if (!mounted) return; + if (confirmed != OkCancelResult.ok) return; + + final success = await showFutureLoadingDialog( + context: context, + future: () async => await space?.leave(), + ); + if (!mounted) return; + if (success.error != null) return; + widget.onBack(); + } } @override Widget build(BuildContext context) { - final client = Matrix.of(context).client; - final activeSpaceId = widget.controller.activeSpaceId; - final activeSpace = activeSpaceId == null - ? null - : client.getRoomById( - activeSpaceId, - ); - final allSpaces = client.rooms.where((room) => room.isSpace); - if (activeSpaceId == null) { - final rootSpaces = allSpaces - .where( - (space) => - !allSpaces.any( - (parentSpace) => parentSpace.spaceChildren - .any((child) => child.roomId == space.id), - ) && - space - .getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)) - .toLowerCase() - .contains( - widget.controller.searchController.text.toLowerCase(), - ), - ) - .toList(); - - return SafeArea( - child: CustomScrollView( - controller: widget.scrollController, - slivers: [ - ChatListHeader(controller: widget.controller), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) { - final rootSpace = rootSpaces[i]; - final displayname = rootSpace.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ); - return Material( - color: Theme.of(context).colorScheme.surface, - child: ListTile( - leading: Avatar( - mxContent: rootSpace.avatar, - name: displayname, - ), - title: Text( - displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - L10n.of(context)!.numChats( - rootSpace.spaceChildren.length.toString(), - ), - ), - onTap: () => - widget.controller.setActiveSpace(rootSpace.id), - onLongPress: () => - _onSpaceChildContextMenu(null, rootSpace), - trailing: const Icon(Icons.chevron_right_outlined), - ), - ); - }, - childCount: rootSpaces.length, - ), - ), - ], + final room = Matrix.of(context).client.getRoomById(widget.spaceId); + final displayname = + room?.getLocalizedDisplayname() ?? L10n.of(context)!.nothingFound; + return Scaffold( + appBar: AppBar( + leading: Center( + child: CloseButton( + onPressed: widget.onBack, + ), ), - ); - } - - final parentSpace = allSpaces.firstWhereOrNull( - (space) => - space.spaceChildren.any((child) => child.roomId == activeSpaceId), - ); - return PopScope( - canPop: parentSpace == null, - onPopInvoked: (pop) async { - if (pop) return; - if (parentSpace != null) { - widget.controller.setActiveSpace(parentSpace.id); - } - }, - child: SafeArea( - child: CustomScrollView( - controller: widget.scrollController, - slivers: [ - ChatListHeader(controller: widget.controller, globalSearch: false), - SliverAppBar( - automaticallyImplyLeading: false, - primary: false, - titleSpacing: 0, - title: ListTile( - leading: BackButton( - onPressed: () => - widget.controller.setActiveSpace(parentSpace?.id), + titleSpacing: 0, + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: Avatar( + mxContent: room?.avatar, + name: displayname, + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + ), + title: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: room == null + ? null + : Text( + L10n.of(context)!.countChatsAndCountParticipants( + room.spaceChildren.length, + room.summary.mJoinedMemberCount ?? 1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - title: Text( - parentSpace == null - ? L10n.of(context)!.allSpaces - : parentSpace.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ), - ), - trailing: IconButton( - icon: loading - ? const CircularProgressIndicator.adaptive(strokeWidth: 2) - : const Icon(Icons.refresh_outlined), - onPressed: loading ? null : _refresh, + ), + actions: [ + PopupMenuButton( + onSelected: _onSpaceAction, + itemBuilder: (context) => [ + PopupMenuItem( + value: SpaceActions.settings, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.settings_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.settings), + ], ), ), - ), - Builder( - builder: (context) { - final response = _lastResponse[activeSpaceId]; - final error = this.error; - if (error != null) { - return SliverFillRemaining( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text(error.toLocalizedString(context)), - ), - IconButton( - onPressed: _refresh, - icon: const Icon(Icons.refresh_outlined), - ), - ], - ), - ); - } - if (response == null) { - return SliverFillRemaining( - child: Center( - child: Text(L10n.of(context)!.loadingPleaseWait), - ), - ); - } - final spaceChildren = response.rooms; - final canLoadMore = response.nextBatch != null; - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) { - if (canLoadMore && i == spaceChildren.length) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: OutlinedButton.icon( - label: loading - ? const LinearProgressIndicator() - : Text(L10n.of(context)!.loadMore), - icon: const Icon(Icons.chevron_right_outlined), - onPressed: loading - ? null - : () { - loadHierarchy(response.nextBatch); - }, + PopupMenuItem( + value: SpaceActions.invite, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_add_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.invite), + ], + ), + ), + PopupMenuItem( + value: SpaceActions.leave, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.leave), + ], + ), + ), + ], + ), + ], + ), + body: room == null + ? const Center( + child: Icon( + Icons.search_outlined, + size: 80, + ), + ) + : StreamBuilder( + stream: room.client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, snapshot) { + final joinedRooms = room.spaceChildren + .map((child) { + final roomId = child.roomId; + if (roomId == null) return null; + return room.client.getRoomById(roomId); + }) + .whereType() + .where((room) => room.membership != Membership.leave) + .toList(); + + // Sort rooms by last activity + joinedRooms.sort( + (b, a) => (a.lastEvent?.originServerTs ?? + DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo( + b.lastEvent?.originServerTs ?? + DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + final joinedParents = room.spaceParents + .map((parent) { + final roomId = parent.roomId; + if (roomId == null) return null; + return room.client.getRoomById(roomId); + }) + .whereType() + .toList(); + final filter = _filterController.text.trim().toLowerCase(); + return CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + toolbarHeight: 72, + scrolledUnderElevation: 0, + backgroundColor: Colors.transparent, + automaticallyImplyLeading: false, + title: TextField( + controller: _filterController, + onChanged: (_) => setState(() {}), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + fillColor: + Theme.of(context).colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), ), - ); - } - final spaceChild = spaceChildren[i]; - final room = client.getRoomById(spaceChild.roomId); - if (room != null && !room.isSpace) { - return ChatListItem( - room, - onLongPress: () => - _onSpaceChildContextMenu(spaceChild, room), - activeChat: widget.controller.activeChat == room.id, - onTap: () => onChatTap(room, context), - ); - } - final isSpace = spaceChild.roomType == 'm.space'; - final topic = spaceChild.topic?.isEmpty ?? true - ? null - : spaceChild.topic; - if (spaceChild.roomId == activeSpaceId) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SearchTitle( - title: spaceChild.name ?? - spaceChild.canonicalAlias ?? - 'Space', - icon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - ), - child: Avatar( - size: 24, - mxContent: spaceChild.avatarUrl, - name: spaceChild.name, - ), - ), + contentPadding: EdgeInsets.zero, + hintText: L10n.of(context)!.search, + hintStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixIcon: IconButton( + onPressed: () {}, + icon: Icon( + Icons.search_outlined, color: Theme.of(context) .colorScheme - .secondaryContainer - .withAlpha(128), - trailing: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: Icon(Icons.edit_outlined), - ), - onTap: () => _onJoinSpaceChild(spaceChild), - ), - if (activeSpace?.canChangeStateEvent( - EventTypes.SpaceChild, - ) == - true) - Material( - child: ListTile( - leading: const CircleAvatar( - child: Icon(Icons.group_add_outlined), - ), - title: - Text(L10n.of(context)!.addChatOrSubSpace), - trailing: - const Icon(Icons.chevron_right_outlined), - onTap: _addChatOrSubSpace, - ), - ), - ], - ); - } - final name = spaceChild.name ?? - spaceChild.canonicalAlias ?? - L10n.of(context)!.chat; - if (widget.controller.isSearchMode && - !name.toLowerCase().contains( - widget.controller.searchController.text - .toLowerCase(), - )) { - return const SizedBox.shrink(); - } - return Material( - child: ListTile( - leading: Avatar( - mxContent: spaceChild.avatarUrl, - name: spaceChild.name, - ), - title: Row( - children: [ - Expanded( - child: Text( - name, - maxLines: 1, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - if (!isSpace) ...[ - const Icon( - Icons.people_outline, - size: 16, - ), - const SizedBox(width: 4), - Text( - spaceChild.numJoinedMembers.toString(), - style: const TextStyle(fontSize: 14), - ), - ], - ], - ), - onTap: () => room?.isSpace == true - ? widget.controller.setActiveSpace(room!.id) - : _onSpaceChildContextMenu(spaceChild, room), - onLongPress: () => - _onSpaceChildContextMenu(spaceChild, room), - subtitle: Text( - topic ?? - (isSpace - ? L10n.of(context)!.enterSpace - : L10n.of(context)!.enterRoom), - maxLines: 1, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, + .onPrimaryContainer, ), ), - trailing: isSpace - ? const Icon(Icons.chevron_right_outlined) - : null, ), - ); - }, - childCount: spaceChildren.length + (canLoadMore ? 1 : 0), - ), + ), + ), + SliverList.builder( + itemCount: joinedParents.length, + itemBuilder: (context, i) { + final displayname = + joinedParents[i].getLocalizedDisplayname(); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListTile( + minVerticalPadding: 0, + leading: Icon( + Icons.adaptive.arrow_back_outlined, + size: 16, + ), + title: Row( + children: [ + Avatar( + mxContent: joinedParents[i].avatar, + name: displayname, + size: Avatar.defaultSize / 2, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 4, + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(displayname)), + ], + ), + onTap: () => + widget.toParentSpace(joinedParents[i].id), + ), + ), + ); + }, + ), + SliverList.builder( + itemCount: joinedRooms.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return SearchTitle( + title: L10n.of(context)!.joinedChats, + icon: const Icon(Icons.chat_outlined), + ); + } + i--; + final room = joinedRooms[i]; + return ChatListItem( + room, + filter: filter, + onTap: () => widget.onChatTab(room), + onLongPress: () => widget.onChatContext(room), + activeChat: widget.activeChat == room.id, + ); + }, + ), + SliverList.builder( + itemCount: _discoveredChildren.length + 2, + itemBuilder: (context, i) { + if (i == 0) { + return SearchTitle( + title: L10n.of(context)!.discover, + icon: const Icon(Icons.explore_outlined), + ); + } + i--; + if (i == _discoveredChildren.length) { + if (_noMoreRooms) { + return Padding( + padding: const EdgeInsets.all(12.0), + child: Center( + child: Text( + L10n.of(context)!.noMoreChatsFound, + style: const TextStyle(fontSize: 13), + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 2.0, + ), + child: TextButton( + onPressed: _isLoading ? null : _loadHierarchy, + child: _isLoading + ? LinearProgressIndicator( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ) + : Text(L10n.of(context)!.loadMore), + ), + ); + } + final item = _discoveredChildren[i]; + final displayname = item.name ?? + item.canonicalAlias ?? + L10n.of(context)!.emptyChat; + if (!displayname.toLowerCase().contains(filter)) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: () => _joinChildRoom(item), + leading: Avatar( + mxContent: item.avatarUrl, + name: displayname, + borderRadius: item.roomType == 'm.space' + ? BorderRadius.circular( + AppConfig.borderRadius / 2, + ) + : null, + ), + title: Row( + children: [ + Expanded( + child: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.add_circle_outline_outlined), + ], + ), + subtitle: Text( + item.topic ?? + L10n.of(context)!.countParticipants( + item.numJoinedMembers, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + }, + ), + ], ); }, ), - ], - ), - ), ); } } -enum SpaceChildContextAction { - join, +enum SpaceActions { + settings, + invite, leave, - removeFromSpace, } - -enum AddRoomType { chat, subspace } diff --git a/lib/pages/chat_list/start_chat_fab.dart b/lib/pages/chat_list/start_chat_fab.dart deleted file mode 100644 index c6a74c0f..00000000 --- a/lib/pages/chat_list/start_chat_fab.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; - -import '../../config/themes.dart'; -import 'chat_list.dart'; - -class StartChatFloatingActionButton extends StatelessWidget { - final ActiveFilter activeFilter; - final ValueNotifier scrolledToTop; - final bool roomsIsEmpty; - final void Function() createNewSpace; - - const StartChatFloatingActionButton({ - super.key, - required this.activeFilter, - required this.scrolledToTop, - required this.roomsIsEmpty, - required this.createNewSpace, - }); - - void _onPressed(BuildContext context) async { - switch (activeFilter) { - case ActiveFilter.allChats: - case ActiveFilter.messages: - context.go('/rooms/newprivatechat'); - break; - case ActiveFilter.groups: - context.go('/rooms/newgroup'); - break; - case ActiveFilter.spaces: - createNewSpace(); - break; - } - } - - IconData get icon { - switch (activeFilter) { - case ActiveFilter.allChats: - case ActiveFilter.messages: - return Icons.add_outlined; - case ActiveFilter.groups: - return Icons.group_add_outlined; - case ActiveFilter.spaces: - return Icons.workspaces_outlined; - } - } - - String getLabel(BuildContext context) { - switch (activeFilter) { - case ActiveFilter.allChats: - case ActiveFilter.messages: - return roomsIsEmpty - ? L10n.of(context)!.startFirstChat - : L10n.of(context)!.newChat; - case ActiveFilter.groups: - return L10n.of(context)!.newGroup; - case ActiveFilter.spaces: - return L10n.of(context)!.newSpace; - } - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: scrolledToTop, - builder: (context, scrolledToTop, _) => AnimatedSize( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - clipBehavior: Clip.none, - child: scrolledToTop - ? FloatingActionButton.extended( - onPressed: () => _onPressed(context), - icon: Icon(icon), - label: Text( - getLabel(context), - overflow: TextOverflow.fade, - ), - ) - : FloatingActionButton( - onPressed: () => _onPressed(context), - child: Icon(icon), - ), - ), - ); - } -} diff --git a/lib/pages/chat_list/utils/on_chat_tap.dart b/lib/pages/chat_list/utils/on_chat_tap.dart deleted file mode 100644 index d24af1fb..00000000 --- a/lib/pages/chat_list/utils/on_chat_tap.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/chat/send_file_dialog.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -void onChatTap(Room room, BuildContext context) async { - if (room.membership == Membership.invite) { - final inviterId = - room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId; - final inviteAction = await showModalActionSheet( - context: context, - message: room.isDirectChat - ? L10n.of(context)!.invitePrivateChat - : L10n.of(context)!.inviteGroupChat, - title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), - actions: [ - SheetAction( - key: InviteActions.accept, - label: L10n.of(context)!.accept, - icon: Icons.check_outlined, - isDefaultAction: true, - ), - SheetAction( - key: InviteActions.decline, - label: L10n.of(context)!.decline, - icon: Icons.close_outlined, - isDestructiveAction: true, - ), - SheetAction( - key: InviteActions.block, - label: L10n.of(context)!.block, - icon: Icons.block_outlined, - isDestructiveAction: true, - ), - ], - ); - if (inviteAction == null) return; - if (inviteAction == InviteActions.block) { - context.go('/rooms/settings/security/ignorelist', extra: inviterId); - return; - } - if (inviteAction == InviteActions.decline) { - await showFutureLoadingDialog( - context: context, - future: room.leave, - ); - return; - } - final joinResult = await showFutureLoadingDialog( - context: context, - future: () async { - final waitForRoom = room.client.waitForRoomInSync( - room.id, - join: true, - ); - await room.join(); - await waitForRoom; - }, - ); - if (joinResult.error != null) return; - } - - if (room.membership == Membership.ban) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat), - ), - ); - return; - } - - if (room.membership == Membership.leave) { - context.go('/rooms/archive/${room.id}'); - return; - } - - // Share content into this room - final shareContent = Matrix.of(context).shareContent; - if (shareContent != null) { - final shareFile = shareContent.tryGet('file'); - if (shareContent.tryGet('msgtype') == 'chat.fluffy.shared_file' && - shareFile != null) { - await showDialog( - context: context, - useRootNavigator: false, - builder: (c) => SendFileDialog( - files: [shareFile], - room: room, - ), - ); - Matrix.of(context).shareContent = null; - } else { - final consent = await showOkCancelAlertDialog( - context: context, - title: L10n.of(context)!.forward, - message: L10n.of(context)!.forwardMessageTo( - room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), - ), - okLabel: L10n.of(context)!.forward, - cancelLabel: L10n.of(context)!.cancel, - ); - if (consent == OkCancelResult.cancel) { - Matrix.of(context).shareContent = null; - return; - } - if (consent == OkCancelResult.ok) { - room.sendEvent(shareContent); - Matrix.of(context).shareContent = null; - } - } - } - - context.go('/rooms/${room.id}'); -} - -enum InviteActions { - accept, - decline, - block, -} diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index 86f48fe8..0b505d59 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -185,12 +185,6 @@ class SettingsStyleView extends StatelessWidget { storeKey: SettingKeys.showPresences, defaultValue: AppConfig.showPresences, ), - SettingsSwitchListTile.adaptive( - title: L10n.of(context)!.separateChatTypes, - onChanged: (b) => AppConfig.separateChatTypes = b, - storeKey: SettingKeys.separateChatTypes, - defaultValue: AppConfig.separateChatTypes, - ), Divider( height: 1, color: Theme.of(context).dividerColor, diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 2066536d..180f3437 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -15,6 +15,8 @@ class Avatar extends StatelessWidget { final Client? client; final String? presenceUserId; final Color? presenceBackgroundColor; + final BorderRadius? borderRadius; + final IconData? icon; const Avatar({ this.mxContent, @@ -24,6 +26,8 @@ class Avatar extends StatelessWidget { this.client, this.presenceUserId, this.presenceBackgroundColor, + this.borderRadius, + this.icon, super.key, }); @@ -50,18 +54,25 @@ class Avatar extends StatelessWidget { ), ), ); - final borderRadius = BorderRadius.circular(size / 2); + final borderRadius = this.borderRadius ?? BorderRadius.circular(size / 2); final presenceUserId = this.presenceUserId; final color = noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor; final container = Stack( children: [ - ClipRRect( - borderRadius: borderRadius, - child: Container( - width: size, - height: size, + SizedBox( + width: size, + height: size, + child: Material( color: color, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide( + width: 0, + color: Theme.of(context).dividerColor, + ), + ), + clipBehavior: Clip.hardEdge, child: noPic ? textWidget : MxcImage( @@ -75,48 +86,49 @@ class Avatar extends StatelessWidget { ), ), ), - PresenceBuilder( - client: client, - userId: presenceUserId, - builder: (context, presence) { - if (presence == null || - (presence.presence == PresenceType.offline && - presence.lastActiveTimestamp == null)) { - return const SizedBox.shrink(); - } - final dotColor = presence.presence.isOnline - ? Colors.green - : presence.presence.isUnavailable - ? Colors.orange - : Colors.grey; - return Positioned( - bottom: -3, - right: -3, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: presenceBackgroundColor ?? - Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(32), - ), - alignment: Alignment.center, + if (presenceUserId != null) + PresenceBuilder( + client: client, + userId: presenceUserId, + builder: (context, presence) { + if (presence == null || + (presence.presence == PresenceType.offline && + presence.lastActiveTimestamp == null)) { + return const SizedBox.shrink(); + } + final dotColor = presence.presence.isOnline + ? Colors.green + : presence.presence.isUnavailable + ? Colors.orange + : Colors.grey; + return Positioned( + bottom: -3, + right: -3, child: Container( - width: 10, - height: 10, + width: 16, + height: 16, decoration: BoxDecoration( - color: dotColor, - borderRadius: BorderRadius.circular(16), - border: Border.all( - width: 1, - color: Theme.of(context).colorScheme.surface, + color: presenceBackgroundColor ?? + Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(32), + ), + alignment: Alignment.center, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: dotColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + width: 1, + color: Theme.of(context).colorScheme.surface, + ), ), ), ), - ), - ); - }, - ), + ); + }, + ), ], ); if (onTap == null) return container; diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index a6f4c8bd..c270f120 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -3,13 +3,11 @@ import 'package:flutter/material.dart'; class TwoColumnLayout extends StatelessWidget { final Widget mainView; final Widget sideView; - final bool displayNavigationRail; const TwoColumnLayout({ super.key, required this.mainView, required this.sideView, - required this.displayNavigationRail, }); @override Widget build(BuildContext context) { @@ -20,7 +18,7 @@ class TwoColumnLayout extends StatelessWidget { Container( clipBehavior: Clip.antiAlias, decoration: const BoxDecoration(), - width: 360.0 + (displayNavigationRail ? 64 : 0), + width: 384.0, child: mainView, ), Container( diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 4de23a2f..581e3ccf 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -433,10 +433,6 @@ class MatrixState extends State with WidgetsBindingObserver { store.getBool(SettingKeys.hideUnimportantStateEvents) ?? AppConfig.hideUnimportantStateEvents; - AppConfig.separateChatTypes = - store.getBool(SettingKeys.separateChatTypes) ?? - AppConfig.separateChatTypes; - AppConfig.autoplayImages = store.getBool(SettingKeys.autoplayImages) ?? AppConfig.autoplayImages; diff --git a/linux/my_application.cc b/linux/my_application.cc index c185bcd7..0abe77c6 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -60,7 +60,7 @@ static void my_application_activate(GApplication* application) { gtk_window_set_title(window, "FluffyChat"); } - gtk_window_set_default_size(window, 864, 680); + gtk_window_set_default_size(window, 800, 600); gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new();