From 919b0822e5f6ba2cb1f75a197c853fab8427635c Mon Sep 17 00:00:00 2001 From: 20kdc Date: Sun, 3 Apr 2022 17:00:35 +0000 Subject: [PATCH] feat: Groups and Direct Chats virtual spaces option --- assets/l10n/intl_en.arb | 5 + lib/config/app_config.dart | 1 + lib/config/setting_keys.dart | 1 + lib/pages/chat_list/chat_list.dart | 82 ++++---- lib/pages/chat_list/chat_list_view.dart | 9 +- lib/pages/chat_list/spaces_bottom_bar.dart | 75 +++---- lib/pages/chat_list/spaces_entry.dart | 189 ++++++++++++++++++ .../settings_chat/settings_chat_view.dart | 7 + lib/widgets/matrix.dart | 3 + 9 files changed, 285 insertions(+), 87 deletions(-) create mode 100644 lib/pages/chat_list/spaces_entry.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 5c60f73b..494b7573 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2083,6 +2083,11 @@ "type": "text", "placeholders": {} }, + "separateChatTypes": "Separate Direct Chats, Groups, and Spaces", + "@separateChatTypes": { + "type": "text", + "placeholders": {} + }, "setAProfilePicture": "Set a profile picture", "@setAProfilePicture": { "type": "text", diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index be644416..5040b3dc 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -36,6 +36,7 @@ abstract class AppConfig { static bool renderHtml = true; static bool hideRedactedEvents = false; static bool hideUnknownEvents = true; + static bool separateChatTypes = false; static bool autoplayImages = true; static bool sendOnEnter = false; static bool experimentalVoip = false; diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 0e7c0d53..c629201c 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -3,6 +3,7 @@ abstract class SettingKeys { static const String renderHtml = 'chat.fluffy.renderHtml'; static const String hideRedactedEvents = 'chat.fluffy.hideRedactedEvents'; static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents'; + static const String separateChatTypes = 'chat.fluffy.separateChatTypes'; static const String chatColor = 'chat.fluffy.chat_color'; static const String sentry = 'sentry'; static const String theme = 'theme'; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 9580becb..a77699d5 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -13,8 +13,8 @@ import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; +import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import '../../../utils/account_bundles.dart'; import '../../main.dart'; @@ -47,15 +47,15 @@ class ChatListController extends State { StreamSubscription? _intentUriStreamSubscription; - String? _activeSpaceId; + SpacesEntry? _activeSpacesEntry; - String? get activeSpaceId { - final id = _activeSpaceId; - return id != null && Matrix.of(context).client.getRoomById(id) == null - ? null - : _activeSpaceId; + SpacesEntry get activeSpacesEntry { + final id = _activeSpacesEntry; + return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id; } + String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id; + final ScrollController scrollController = ScrollController(); bool scrolledToTop = true; @@ -72,8 +72,8 @@ class ChatListController extends State { } } - void setActiveSpaceId(BuildContext context, String? spaceId) { - setState(() => _activeSpaceId = spaceId); + void setActiveSpacesEntry(BuildContext context, SpacesEntry spaceId) { + setState(() => _activeSpacesEntry = spaceId); } void editSpace(BuildContext context, String spaceId) async { @@ -81,9 +81,30 @@ class ChatListController extends State { VRouter.of(context).toSegments(['spaces', spaceId]); } + // Needs to match GroupsSpacesEntry for 'separate group' checking. List get spaces => Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); + // Note that this could change due to configuration, etc. + // Also be aware that _activeSpacesEntry = null is the expected reset method. + SpacesEntry get defaultSpacesEntry => AppConfig.separateChatTypes + ? DirectChatsSpacesEntry() + : AllRoomsSpacesEntry(); + + List get spacesEntries { + if (AppConfig.separateChatTypes) { + return [ + defaultSpacesEntry, + GroupsSpacesEntry(), + ...spaces.map((space) => SpaceSpacesEntry(space)).toList() + ]; + } + return [ + defaultSpacesEntry, + ...spaces.map((space) => SpaceSpacesEntry(space)).toList() + ]; + } + final selectedRoomIds = {}; bool? crossSigningCached; bool showChatBackupBanner = false; @@ -206,35 +227,6 @@ class ChatListController extends State { super.dispose(); } - bool roomCheck(Room room) { - if (room.isSpace && room.membership == Membership.join && !room.isUnread) { - return false; - } - if (room.getState(EventTypes.RoomCreate)?.content.tryGet('type') == - ClientStoriesExtension.storiesRoomType) { - return false; - } - if (activeSpaceId != null) { - final space = Matrix.of(context).client.getRoomById(activeSpaceId!)!; - if (space.spaceChildren.any((child) => child.roomId == room.id)) { - return true; - } - if (room.spaceParents.any((parent) => parent.roomId == activeSpaceId)) { - return true; - } - if (room.isDirectChat && - room.summary.mHeroes != null && - room.summary.mHeroes!.any((userId) { - final user = space.getState(EventTypes.RoomMember, userId)?.asUser; - return user != null && user.membership == Membership.join; - })) { - return true; - } - return false; - } - return true; - } - void toggleSelection(String roomId) { setState(() => selectedRoomIds.contains(roomId) ? selectedRoomIds.remove(roomId) @@ -370,7 +362,8 @@ class ChatListController extends State { } Future addOrRemoveToSpace() async { - if (activeSpaceId != null) { + final id = activeSpaceId; + if (id != null) { final consent = await showOkCancelAlertDialog( context: context, title: L10n.of(context)!.removeFromSpace, @@ -382,7 +375,7 @@ class ChatListController extends State { ); if (consent != OkCancelResult.ok) return; - final space = Matrix.of(context).client.getRoomById(activeSpaceId!); + final space = Matrix.of(context).client.getRoomById(id); final result = await showFutureLoadingDialog( context: context, future: () async { @@ -458,8 +451,9 @@ class ChatListController extends State { await client.onFirstSync.stream.first; } // Load space members to display DM rooms - if (activeSpaceId != null) { - final space = client.getRoomById(activeSpaceId!)!; + final spaceId = activeSpaceId; + if (spaceId != null) { + final space = client.getRoomById(spaceId)!; final localMembers = space.getParticipants().length; final actualMembersCount = (space.summary.mInvitedMemberCount ?? 0) + (space.summary.mJoinedMemberCount ?? 0); @@ -485,7 +479,7 @@ class ChatListController extends State { void setActiveClient(Client client) { VRouter.of(context).to('/rooms'); setState(() { - _activeSpaceId = null; + _activeSpacesEntry = null; selectedRoomIds.clear(); Matrix.of(context).setActiveClient(client); }); @@ -495,7 +489,7 @@ class ChatListController extends State { void setActiveBundle(String bundle) { VRouter.of(context).to('/rooms'); setState(() { - _activeSpaceId = null; + _activeSpacesEntry = null; selectedRoomIds.clear(); Matrix.of(context).activeBundle = bundle; if (!Matrix.of(context) diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index fbbc136d..b9df3abc 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -294,11 +294,7 @@ class _ChatListViewBodyState extends State<_ChatListViewBody> { Widget child; if (widget.controller.waitForFirstSync && Matrix.of(context).client.prevBatch != null) { - final rooms = Matrix.of(context) - .client - .rooms - .where(widget.controller.roomCheck) - .toList(); + final rooms = widget.controller.activeSpacesEntry.getRooms(context); if (rooms.isEmpty) { child = Column( key: const ValueKey(null), @@ -323,7 +319,8 @@ class _ChatListViewBodyState extends State<_ChatListViewBody> { ], ); } else { - final displayStoriesHeader = widget.controller.activeSpaceId == null; + final displayStoriesHeader = widget.controller.activeSpacesEntry + .shouldShowStoriesHeader(context); child = ListView.builder( key: ValueKey(Matrix.of(context).client.userID.toString() + widget.controller.activeSpaceId.toString()), diff --git a/lib/pages/chat_list/spaces_bottom_bar.dart b/lib/pages/chat_list/spaces_bottom_bar.dart index 59ea3fb6..61057003 100644 --- a/lib/pages/chat_list/spaces_bottom_bar.dart +++ b/lib/pages/chat_list/spaces_bottom_bar.dart @@ -1,10 +1,9 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:salomon_bottom_bar/salomon_bottom_bar.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -14,11 +13,9 @@ class SpacesBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { - final currentIndex = controller.activeSpaceId == null - ? 0 - : controller.spaces - .indexWhere((space) => controller.activeSpaceId == space.id) + - 1; + final foundIndex = controller.spacesEntries.indexWhere( + (se) => spacesEntryRoughEquivalence(se, controller.activeSpacesEntry)); + final currentIndex = foundIndex == -1 ? 0 : foundIndex; return Material( color: Theme.of(context).appBarTheme.backgroundColor, elevation: 6, @@ -39,39 +36,14 @@ class SpacesBottomBar extends StatelessWidget { child: SalomonBottomBar( itemPadding: const EdgeInsets.all(8), currentIndex: currentIndex, - onTap: (i) => controller.setActiveSpaceId( + onTap: (i) => controller.setActiveSpacesEntry( context, - i == 0 ? null : controller.spaces[i - 1].id, + controller.spacesEntries[i], ), selectedItemColor: Theme.of(context).colorScheme.primary, - items: [ - SalomonBottomBarItem( - icon: const Icon(CupertinoIcons.chat_bubble_2), - activeIcon: - const Icon(CupertinoIcons.chat_bubble_2_fill), - title: Text(L10n.of(context)!.allChats), - ), - ...controller.spaces - .map((space) => SalomonBottomBarItem( - icon: InkWell( - borderRadius: BorderRadius.circular(28), - onTap: () => controller.setActiveSpaceId( - context, - space.id, - ), - onLongPress: () => - controller.editSpace(context, space.id), - child: Avatar( - mxContent: space.avatar, - name: space.displayname, - size: 24, - fontSize: 12, - ), - ), - title: Text(space.displayname), - )) - .toList(), - ], + items: controller.spacesEntries + .map((entry) => _buildSpacesEntryUI(context, entry)) + .toList(), ), ), ); @@ -79,4 +51,33 @@ class SpacesBottomBar extends StatelessWidget { ), ); } + + SalomonBottomBarItem _buildSpacesEntryUI( + BuildContext context, SpacesEntry entry) { + final space = entry.getSpace(context); + if (space != null) { + return SalomonBottomBarItem( + icon: InkWell( + borderRadius: BorderRadius.circular(28), + onTap: () => controller.setActiveSpacesEntry( + context, + entry, + ), + onLongPress: () => controller.editSpace(context, space.id), + child: Avatar( + mxContent: space.avatar, + name: space.displayname, + size: 24, + fontSize: 12, + ), + ), + title: Text(entry.getName(context)), + ); + } + return SalomonBottomBarItem( + icon: entry.getIcon(false), + activeIcon: entry.getIcon(true), + title: Text(entry.getName(context)), + ); + } } diff --git a/lib/pages/chat_list/spaces_entry.dart b/lib/pages/chat_list/spaces_entry.dart new file mode 100644 index 00000000..a203e3a3 --- /dev/null +++ b/lib/pages/chat_list/spaces_entry.dart @@ -0,0 +1,189 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; +import '../../widgets/matrix.dart'; + +// This is not necessarily a Space, but an abstract categorization of a room. +// More to the point, it's a selectable entry that *could* be a Space. +// Note that view code is in spaces_bottom_bar.dart because of type-specific UI. +// So only really generic functions (so far, anything ChatList cares about) go here. +// If getRoom returns something non-null, then it gets the avatar and such of a Space. +// Otherwise it gets to look like All Rooms. Future work impending. +abstract class SpacesEntry { + const SpacesEntry(); + + // Gets the (translated) name of this entry. + String getName(BuildContext context); + // Gets an icon for this entry (avoided if a space is given) + Icon getIcon(bool active) => active + ? const Icon(CupertinoIcons.chat_bubble_2_fill) + : const Icon(CupertinoIcons.chat_bubble_2); + // If this is a specific Room, returns the space Room for various purposes. + Room? getSpace(BuildContext context) => null; + // Gets a list of rooms - this is done as part of _ChatListViewBodyState to get the full list of rooms visible from this SpacesEntry. + List getRooms(BuildContext context); + // Checks that this entry is still valid. + bool stillValid(BuildContext context) => true; + // Returns true if the Stories header should be shown. + bool shouldShowStoriesHeader(BuildContext context) => false; +} + +// Common room validity checks +bool _roomCheckCommon(Room room, BuildContext context) { + if (room.isSpace && room.membership == Membership.join && !room.isUnread) { + return false; + } + if (room.getState(EventTypes.RoomCreate)?.content.tryGet('type') == + ClientStoriesExtension.storiesRoomType) { + return false; + } + return true; +} + +bool _roomInsideSpace(Room room, Room space) { + if (space.spaceChildren.any((child) => child.roomId == room.id)) { + return true; + } + if (room.spaceParents.any((parent) => parent.roomId == space.id)) { + return true; + } + return false; +} + +// "All rooms" entry. +class AllRoomsSpacesEntry extends SpacesEntry { + static final AllRoomsSpacesEntry _value = AllRoomsSpacesEntry._(); + AllRoomsSpacesEntry._(); + factory AllRoomsSpacesEntry() { + return _value; + } + + @override + String getName(BuildContext context) => L10n.of(context)!.allChats; + + @override + List getRooms(BuildContext context) { + return Matrix.of(context) + .client + .rooms + .where((room) => _roomCheckCommon(room, context)) + .toList(); + } + + @override + bool shouldShowStoriesHeader(BuildContext context) => true; +} + +// "Direct Chats" entry. +class DirectChatsSpacesEntry extends SpacesEntry { + static final DirectChatsSpacesEntry _value = DirectChatsSpacesEntry._(); + DirectChatsSpacesEntry._(); + factory DirectChatsSpacesEntry() { + return _value; + } + + @override + String getName(BuildContext context) => L10n.of(context)!.directChats; + + @override + List getRooms(BuildContext context) { + return Matrix.of(context) + .client + .rooms + .where((room) => room.isDirectChat && _roomCheckCommon(room, context)) + .toList(); + } + + @override + bool shouldShowStoriesHeader(BuildContext context) => true; +} + +// "Groups" entry. +class GroupsSpacesEntry extends SpacesEntry { + static final GroupsSpacesEntry _value = GroupsSpacesEntry._(); + GroupsSpacesEntry._(); + factory GroupsSpacesEntry() { + return _value; + } + + @override + String getName(BuildContext context) => L10n.of(context)!.groups; + + @override + Icon getIcon(bool active) => + active ? const Icon(Icons.group) : const Icon(Icons.group_outlined); + + @override + List getRooms(BuildContext context) { + final rooms = Matrix.of(context).client.rooms; + // Needs to match ChatList's definition of a space. + final spaces = rooms.where((room) => room.isSpace).toList(); + return rooms + .where((room) => + (!room.isDirectChat) && + _roomCheckCommon(room, context) && + separatedGroup(room, spaces)) + .toList(); + } + + bool separatedGroup(Room room, List spaces) { + return !spaces.any((space) => _roomInsideSpace(room, space)); + } +} + +// All rooms associated with a specific space. +class SpaceSpacesEntry extends SpacesEntry { + final Room space; + const SpaceSpacesEntry(this.space); + + @override + String getName(BuildContext context) => space.displayname; + + @override + Room? getSpace(BuildContext context) => space; + + @override + List getRooms(BuildContext context) { + return Matrix.of(context) + .client + .rooms + .where((room) => roomCheck(room, context)) + .toList(); + } + + bool roomCheck(Room room, BuildContext context) { + if (!_roomCheckCommon(room, context)) { + return false; + } + if (_roomInsideSpace(room, space)) { + return true; + } + if (true) { + if (room.isDirectChat && + room.summary.mHeroes != null && + room.summary.mHeroes!.any((userId) { + final user = space.getState(EventTypes.RoomMember, userId)?.asUser; + return user != null && user.membership == Membership.join; + })) { + return true; + } + } + return false; + } + + @override + bool stillValid(BuildContext context) => + Matrix.of(context).client.getRoomById(space.id) != null; +} + +// Produces a "rough equivalence" for maintaining the current spaces index. +bool spacesEntryRoughEquivalence(SpacesEntry a, SpacesEntry b) { + if ((a is SpaceSpacesEntry) && (b is SpaceSpacesEntry)) { + return a.space.id == b.space.id; + } + return a == b; +} diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index 8e9c9040..2b381c64 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -76,6 +76,13 @@ class SettingsChatView extends StatelessWidget { child: Icon(Icons.insert_emoticon_outlined), ), ), + const Divider(height: 1), + SettingsSwitchListTile.adaptive( + title: L10n.of(context)!.separateChatTypes, + onChanged: (b) => AppConfig.separateChatTypes = b, + storeKey: SettingKeys.separateChatTypes, + defaultValue: AppConfig.separateChatTypes, + ), ], ), ), diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index c1a5a57b..f14a7e40 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -480,6 +480,9 @@ class MatrixState extends State with WidgetsBindingObserver { store .getItemBool(SettingKeys.hideUnknownEvents, AppConfig.hideUnknownEvents) .then((value) => AppConfig.hideUnknownEvents = value); + store + .getItemBool(SettingKeys.separateChatTypes, AppConfig.separateChatTypes) + .then((value) => AppConfig.separateChatTypes = value); store .getItemBool(SettingKeys.autoplayImages, AppConfig.autoplayImages) .then((value) => AppConfig.autoplayImages = value);