diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d883820d..89f88431 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -174,6 +174,13 @@ "type": "text", "placeholders": {} }, + "numberRoomMembers": "{number} members", + "@numberRoomMembers": { + "type": "number", + "placeholders": { + "number": {} + } + }, "badServerLoginTypesException": "The homeserver supports the login types:\n{serverVersions}\nBut this app supports only:\n{supportedVersions}", "@badServerLoginTypesException": { "type": "text", @@ -210,6 +217,7 @@ "targetName": {} } }, + "suggestedRooms": "Discover groups in this space", "blockDevice": "Block Device", "@blockDevice": { "type": "text", diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index f616e585..68158210 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -8,6 +8,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat_list/chat_list.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/spaces_entry.dart'; import 'package:fluffychat/pages/chat_list/stories_header.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -16,6 +17,7 @@ import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; import '../../utils/stream_extension.dart'; import '../../widgets/matrix.dart'; +import 'spaces_hierarchy_proposal.dart'; class ChatListViewBody extends StatefulWidget { final ChatListController controller; @@ -55,201 +57,206 @@ class _ChatListViewBodyState extends State { if (widget.controller.waitForFirstSync && Matrix.of(context).client.prevBatch != null) { final rooms = widget.controller.activeSpacesEntry.getRooms(context); - if (rooms.isEmpty) { - child = Column( - key: const ValueKey(null), - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/private_chat_wallpaper.png', - width: 160, - height: 160, - ), - Center( - child: Text( - L10n.of(context)!.startYourFirstChat, - textAlign: TextAlign.start, - style: const TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - ), - const SizedBox(height: 160), - ], - ); - } else { - final displayStoriesHeader = widget.controller.activeSpacesEntry - .shouldShowStoriesHeader(context); - child = ListView.builder( - key: ValueKey(Matrix.of(context).client.userID.toString() + - widget.controller.activeSpaceId.toString() + - widget.controller.activeSpacesEntry.runtimeType.toString()), - controller: widget.controller.scrollController, - // add +1 space below in order to properly scroll below the spaces bar - itemCount: rooms.length + (displayStoriesHeader ? 2 : 1), - itemBuilder: (BuildContext context, int i) { - if (displayStoriesHeader) { - if (i == 0) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const ConnectionStatusHeader(), - if (roomSearchResult != null) ...[ - _SearchTitle( - title: L10n.of(context)!.publicRooms, - icon: Icons.explore_outlined, - ), - AnimatedContainer( - height: roomSearchResult.chunk.isEmpty ? 0 : 106, - duration: const Duration(milliseconds: 250), - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: roomSearchResult.chunk.length, - itemBuilder: (context, i) => _SearchItem( - title: roomSearchResult.chunk[i].name ?? - roomSearchResult - .chunk[i].canonicalAlias?.localpart ?? - L10n.of(context)!.group, - avatar: roomSearchResult.chunk[i].avatarUrl, - onPressed: () => showModalBottomSheet( - context: context, - builder: (c) => PublicRoomBottomSheet( - roomAlias: - roomSearchResult.chunk[i].canonicalAlias ?? - roomSearchResult.chunk[i].roomId, - outerContext: context, - chunk: roomSearchResult.chunk[i], - ), - ), - ), - ), - ), - ], - if (userSearchResult != null) ...[ - _SearchTitle( - title: L10n.of(context)!.users, - icon: Icons.group_outlined, - ), - AnimatedContainer( - height: userSearchResult.results.isEmpty ? 0 : 106, - duration: const Duration(milliseconds: 250), - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: 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: () => showModalBottomSheet( - context: context, - builder: (c) => ProfileBottomSheet( - userId: userSearchResult.results[i].userId, - outerContext: context, - ), - ), - ), - ), - ), - ], - if (widget.controller.isSearchMode) - _SearchTitle( - title: L10n.of(context)!.stories, - icon: Icons.camera_alt_outlined, - ), - StoriesHeader( - filter: widget.controller.searchController.text, + + final displayStoriesHeader = widget.controller.activeSpacesEntry + .shouldShowStoriesHeader(context) || + rooms.isEmpty; + child = ListView.builder( + key: ValueKey(Matrix.of(context).client.userID.toString() + + widget.controller.activeSpaceId.toString() + + widget.controller.activeSpacesEntry.runtimeType.toString()), + controller: widget.controller.scrollController, + // add +1 space below in order to properly scroll below the spaces bar + itemCount: rooms.length + (displayStoriesHeader ? 2 : 1), + itemBuilder: (BuildContext context, int i) { + if (displayStoriesHeader) { + if (i == 0) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ConnectionStatusHeader(), + if (roomSearchResult != null) ...[ + SearchTitle( + title: L10n.of(context)!.publicRooms, + icon: const Icon(Icons.explore_outlined), ), AnimatedContainer( - height: !widget.controller.isSearchMode && - widget.controller.showChatBackupBanner - ? 54 - : 0, - duration: const Duration(milliseconds: 300), + height: roomSearchResult.chunk.isEmpty ? 0 : 106, + duration: const Duration(milliseconds: 250), clipBehavior: Clip.hardEdge, - curve: Curves.bounceInOut, decoration: const BoxDecoration(), - child: Material( - color: Theme.of(context).colorScheme.surface, - child: ListTile( - leading: CircleAvatar( - radius: Avatar.defaultSize / 2, - child: - const Icon(Icons.enhanced_encryption_outlined), - backgroundColor: - Theme.of(context).colorScheme.surfaceVariant, - foregroundColor: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - title: Text( - (Matrix.of(context) - .client - .encryption - ?.keyManager - .enabled == - true) - ? L10n.of(context)!.unlockOldMessages - : L10n.of(context)!.enableAutoBackups, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: roomSearchResult.chunk.length, + itemBuilder: (context, i) => _SearchItem( + title: roomSearchResult.chunk[i].name ?? + roomSearchResult + .chunk[i].canonicalAlias?.localpart ?? + L10n.of(context)!.group, + avatar: roomSearchResult.chunk[i].avatarUrl, + onPressed: () => showModalBottomSheet( + context: context, + builder: (c) => PublicRoomBottomSheet( + roomAlias: + roomSearchResult.chunk[i].canonicalAlias ?? + roomSearchResult.chunk[i].roomId, + outerContext: context, + chunk: roomSearchResult.chunk[i], ), ), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: widget.controller.firstRunBootstrapAction, ), ), ), - AnimatedContainer( - height: widget.controller.isTorBrowser ? 64 : 0, - duration: const Duration(milliseconds: 300), - clipBehavior: Clip.hardEdge, - curve: Curves.bounceInOut, - 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: widget.controller.dehydrate, - ), - ), - ), - if (widget.controller.isSearchMode) - _SearchTitle( - title: L10n.of(context)!.chats, - icon: Icons.chat_outlined, - ), ], - ); - } - i--; + if (userSearchResult != null) ...[ + SearchTitle( + title: L10n.of(context)!.users, + icon: const Icon(Icons.group_outlined), + ), + AnimatedContainer( + height: userSearchResult.results.isEmpty ? 0 : 106, + duration: const Duration(milliseconds: 250), + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: 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: () => showModalBottomSheet( + context: context, + builder: (c) => ProfileBottomSheet( + userId: userSearchResult.results[i].userId, + outerContext: context, + ), + ), + ), + ), + ), + ], + if (widget.controller.isSearchMode) + SearchTitle( + title: L10n.of(context)!.stories, + icon: const Icon(Icons.camera_alt_outlined), + ), + StoriesHeader( + filter: widget.controller.searchController.text, + ), + AnimatedContainer( + height: !widget.controller.isSearchMode && + widget.controller.showChatBackupBanner + ? 54 + : 0, + duration: const Duration(milliseconds: 300), + clipBehavior: Clip.hardEdge, + curve: Curves.bounceInOut, + decoration: const BoxDecoration(), + child: Material( + color: Theme.of(context).colorScheme.surface, + child: ListTile( + leading: CircleAvatar( + radius: Avatar.defaultSize / 2, + child: const Icon(Icons.enhanced_encryption_outlined), + backgroundColor: + Theme.of(context).colorScheme.surfaceVariant, + foregroundColor: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text( + (Matrix.of(context) + .client + .encryption + ?.keyManager + .enabled == + true) + ? L10n.of(context)!.unlockOldMessages + : L10n.of(context)!.enableAutoBackups, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + trailing: const Icon(Icons.chevron_right_outlined), + onTap: widget.controller.firstRunBootstrapAction, + ), + ), + ), + AnimatedContainer( + height: widget.controller.isTorBrowser ? 64 : 0, + duration: const Duration(milliseconds: 300), + clipBehavior: Clip.hardEdge, + curve: Curves.bounceInOut, + 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: widget.controller.dehydrate, + ), + ), + ), + if (widget.controller.isSearchMode) + SearchTitle( + title: L10n.of(context)!.chats, + icon: const Icon(Icons.chat_outlined), + ), + if (rooms.isEmpty) + Column( + key: const ValueKey(null), + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/private_chat_wallpaper.png', + width: 160, + height: 160, + ), + Center( + child: Text( + L10n.of(context)!.startYourFirstChat, + textAlign: TextAlign.start, + style: const TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ], + ); } - if (i >= rooms.length) { - return const ListTile(); - } - if (!rooms[i].displayname.toLowerCase().contains( - widget.controller.searchController.text.toLowerCase())) { - return Container(); - } - return ChatListItem( - rooms[i], - selected: widget.controller.selectedRoomIds.contains(rooms[i].id), - onTap: widget.controller.selectMode == SelectMode.select - ? () => widget.controller.toggleSelection(rooms[i].id) + i--; + } + if (i >= rooms.length) { + return SpacesHierarchyProposals( + space: widget.controller.activeSpacesEntry.getSpace(context)?.id, + query: widget.controller.isSearchMode + ? widget.controller.searchController.text : null, - onLongPress: () => widget.controller.toggleSelection(rooms[i].id), - activeChat: widget.controller.activeChat == rooms[i].id, ); - }, - ); - } + } + if (!rooms[i].displayname.toLowerCase().contains( + widget.controller.searchController.text.toLowerCase())) { + return Container(); + } + return ChatListItem( + rooms[i], + selected: widget.controller.selectedRoomIds.contains(rooms[i].id), + onTap: widget.controller.selectMode == SelectMode.select + ? () => widget.controller.toggleSelection(rooms[i].id) + : null, + onLongPress: () => widget.controller.toggleSelection(rooms[i].id), + activeChat: widget.controller.activeChat == rooms[i].id, + ); + }, + ); } else { const dummyChatCount = 5; final titleColor = @@ -361,54 +368,11 @@ class _ChatListViewBodyState extends State { } } -class _SearchTitle extends StatelessWidget { - final String title; - final IconData icon; - const _SearchTitle({ - required this.title, - required this.icon, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) => Container( - decoration: BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - )), - color: Theme.of(context).colorScheme.surface, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - children: [ - Icon(icon, size: 16), - const SizedBox(width: 16), - Text(title, - textAlign: TextAlign.left, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 12, - fontWeight: FontWeight.bold, - )), - ], - ), - ), - ), - ); -} - class _SearchItem extends StatelessWidget { final String title; final Uri? avatar; final void Function() onPressed; + const _SearchItem({ required this.title, this.avatar, diff --git a/lib/pages/chat_list/recommended_room_list_item.dart b/lib/pages/chat_list/recommended_room_list_item.dart new file mode 100644 index 00000000..d315ad5d --- /dev/null +++ b/lib/pages/chat_list/recommended_room_list_item.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_list/spaces_hierarchy_proposal.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; + +class RecommendedRoomListItem extends StatelessWidget { + final SpaceRoomsChunk room; + + const RecommendedRoomListItem({Key? key, required this.room}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final leading = Avatar( + mxContent: room.avatarUrl, + name: room.name, + ); + final title = Row( + children: [ + Expanded( + child: Text( + room.name ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.bodyText1!.color, + ), + ), + ), + // number of joined users + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text.rich( + TextSpan(children: [ + WidgetSpan( + child: Tooltip( + child: const Icon( + Icons.people_outlined, + size: 20, + ), + message: L10n.of(context)! + .numberRoomMembers(room.numJoinedMembers), + ), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + TextSpan(text: ' ${room.numJoinedMembers}') + ]), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyText2!.color, + ), + ), + ), + ], + ); + final subtitle = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + room.topic ?? 'topic', + softWrap: false, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).textTheme.bodyText2!.color, + ), + ), + ), + ], + ); + void handler() => showModalBottomSheet( + context: context, + builder: (c) => PublicRoomBottomSheet( + roomAlias: room.canonicalAlias!, + outerContext: context, + chunk: room, + ), + ); + if (room.roomType == 'm.space') { + return Material( + color: Colors.transparent, + child: ExpansionTile( + leading: leading, + title: title, + subtitle: subtitle, + onExpansionChanged: (open) { + if (!open) handler(); + }, + children: [ + SpacesHierarchyProposals(space: room.roomId), + ], + ), + ); + } else { + return Material( + color: Colors.transparent, + child: ListTile( + leading: leading, + title: title, + subtitle: subtitle, + onTap: handler, + ), + ); + } + } +} diff --git a/lib/pages/chat_list/search_title.dart b/lib/pages/chat_list/search_title.dart new file mode 100644 index 00000000..7492de0b --- /dev/null +++ b/lib/pages/chat_list/search_title.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class SearchTitle extends StatelessWidget { + final String title; + final Widget icon; + final Widget? trailing; + final void Function()? onTap; + + const SearchTitle({ + required this.title, + required this.icon, + this.trailing, + this.onTap, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Material( + shape: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + color: Theme.of(context).colorScheme.surface, + child: InkWell( + onTap: onTap, + splashColor: Theme.of(context).colorScheme.surface, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: IconTheme( + data: Theme.of(context).iconTheme.copyWith(size: 16), + child: Row( + children: [ + icon, + const SizedBox(width: 16), + Text(title, + textAlign: TextAlign.left, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + fontWeight: FontWeight.bold, + )), + if (trailing != null) + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: trailing!, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/pages/chat_list/spaces_drawer.dart b/lib/pages/chat_list/spaces_drawer.dart index c9e5d6be..e61202c4 100644 --- a/lib/pages/chat_list/spaces_drawer.dart +++ b/lib/pages/chat_list/spaces_drawer.dart @@ -1,11 +1,16 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'chat_list.dart'; +import 'spaces_drawer_entry.dart'; class SpacesDrawer extends StatelessWidget { final ChatListController controller; @@ -14,19 +19,62 @@ class SpacesDrawer extends StatelessWidget { @override Widget build(BuildContext context) { - final currentIndex = controller.spacesEntries.indexWhere((space) => - controller.activeSpacesEntry.runtimeType == space.runtimeType && - (controller.activeSpaceId == space.getSpace(context)?.id)); + final spaceEntries = controller.spacesEntries + .map((e) => SpacesEntryMaybeChildren.buildIfTopLevel( + e, controller.spacesEntries)) + .whereNotNull() + .toList(); - final Map spaceHierarchy = - Map.fromEntries(controller.spacesEntries.map((e) => MapEntry(e, null))); + final childSpaceIds = {}; + + final spacesHierarchy = []; + + final matrix = Matrix.of(context); + for (final entry in spaceEntries) { + if (entry.spacesEntry is SpaceSpacesEntry) { + final space = entry.spacesEntry.getSpace(context); + if (space != null && space.spaceChildren.isNotEmpty) { + final children = space.spaceChildren; + // computing the children space entries + final childrenSpaceEntries = spaceEntries.where((element) { + // current ID + final id = element.spacesEntry.getSpace(context)?.id; + + // comparing against the supposed IDs of the children and checking + // whether the room is already joined + return children.any( + (child) => + child.roomId == id && + matrix.client.rooms + .any((joinedRoom) => child.roomId == joinedRoom.id), + ); + }); + childSpaceIds.addAll(childrenSpaceEntries + .map((e) => e.spacesEntry.getSpace(context)?.id) + .whereNotNull()); + entry.children.addAll(childrenSpaceEntries); + spacesHierarchy.add(entry); + } else { + if (space?.spaceParents.isEmpty ?? false) { + spacesHierarchy.add(entry); + } + } + } else { + spacesHierarchy.add(entry); + } + } + + spacesHierarchy.removeWhere((element) => + childSpaceIds.contains(element.spacesEntry.getSpace(context)?.id)); + + // final spacesHierarchy = spaceEntries; // TODO(TheOeWithTheBraid): wait for space hierarchy https://gitlab.com/famedly/company/frontend/libraries/matrix_api_lite/-/merge_requests/58 return ListView.builder( - itemCount: spaceHierarchy.length + 1, + itemCount: spacesHierarchy.length + 1, itemBuilder: (context, i) { - if (i == spaceHierarchy.length) { + if (i == spacesHierarchy.length) { return ListTile( leading: CircleAvatar( radius: Avatar.defaultSize / 2, @@ -43,54 +91,86 @@ class SpacesDrawer extends StatelessWidget { }, ); } - final space = spaceHierarchy.keys.toList()[i]; - final room = space.getSpace(context); - final active = currentIndex == i; - return ListTile( - selected: active, - leading: room == null - ? CircleAvatar( - child: space.getIcon(active), - radius: Avatar.defaultSize / 2, - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - ) - : Avatar( - mxContent: room.avatar, - name: space.getName(context), - ), - title: Text( - space.getName(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: room?.topic.isEmpty ?? true - ? null - : Tooltip( - message: room!.topic, - child: Text( - room.topic.replaceAll('\n', ' '), - softWrap: false, - overflow: TextOverflow.fade, - ), - ), - onTap: () => controller.setActiveSpacesEntry( - context, - space, - ), - trailing: room != null - ? SizedBox( - width: 32, - child: IconButton( - splashRadius: 24, - icon: const Icon(Icons.edit_outlined), - tooltip: L10n.of(context)!.edit, - onPressed: () => controller.editSpace(context, room.id), - ), - ) - : const Icon(Icons.arrow_forward_ios_outlined), + final space = spacesHierarchy[i]; + return SpacesDrawerEntry( + entry: space, + controller: controller, ); }, ); } } + +class SpacesEntryMaybeChildren { + final SpacesEntry spacesEntry; + + final Set children; + + const SpacesEntryMaybeChildren(this.spacesEntry, [this.children = const {}]); + + static SpacesEntryMaybeChildren? buildIfTopLevel( + SpacesEntry entry, List allEntries, + [String? parent]) { + if (entry is SpaceSpacesEntry) { + final room = entry.space; + if ((parent == null && room.spaceParents.isNotEmpty) || + (parent != null && + !room.spaceParents.any((element) => element.roomId == parent))) { + return null; + } else { + final children = allEntries + .where((element) => + element is SpaceSpacesEntry && + element.space.spaceParents.any((parent) => + parent.roomId == room.id /*&& (parent.canonical ?? true)*/)) + .toList(); + return SpacesEntryMaybeChildren( + entry, + children + .map((e) => buildIfTopLevel(e, allEntries, room.id)) + .whereNotNull() + .toSet()); + } + } else { + return SpacesEntryMaybeChildren(entry); + } + } + + bool isActiveOfChild(ChatListController controller) => + spacesEntry == controller.activeSpacesEntry || + children.any( + (element) => element.isActiveOfChild(controller), + ); + + Map toJson() => { + 'entry': spacesEntry is SpaceSpacesEntry + ? (spacesEntry as SpaceSpacesEntry).space.id + : spacesEntry.runtimeType.toString(), + if (spacesEntry is SpaceSpacesEntry) + 'rawSpaceParents': (spacesEntry as SpaceSpacesEntry) + .space + .spaceParents + .map((e) => + {'roomId': e.roomId, 'canonical': e.canonical, 'via': e.via}) + .toList(), + if (spacesEntry is SpaceSpacesEntry) + 'rawSpaceChildren': (spacesEntry as SpaceSpacesEntry) + .space + .spaceChildren + .map( + (e) => { + 'roomId': e.roomId, + 'suggested': e.suggested, + 'via': e.via, + 'order': e.order + }, + ) + .toList(), + 'children': children.map((e) => e.toJson()).toList(), + }; + + @override + String toString() { + return jsonEncode(toJson()); + } +} diff --git a/lib/pages/chat_list/spaces_drawer_entry.dart b/lib/pages/chat_list/spaces_drawer_entry.dart new file mode 100644 index 00000000..d5e3f3bd --- /dev/null +++ b/lib/pages/chat_list/spaces_drawer_entry.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/chat_list/spaces_drawer.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class SpacesDrawerEntry extends StatelessWidget { + final SpacesEntryMaybeChildren entry; + final ChatListController controller; + + const SpacesDrawerEntry( + {Key? key, required this.entry, required this.controller}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final space = entry.spacesEntry; + final room = space.getSpace(context); + + final active = controller.activeSpacesEntry == entry.spacesEntry; + final leading = room == null + ? CircleAvatar( + child: space.getIcon(active), + radius: Avatar.defaultSize / 2, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + ) + : Avatar( + mxContent: room.avatar, + name: space.getName(context), + ); + final title = Text( + space.getName(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + final subtitle = room?.topic.isEmpty ?? true + ? null + : Tooltip( + message: room!.topic, + child: Text( + room.topic.replaceAll('\n', ' '), + softWrap: false, + overflow: TextOverflow.fade, + ), + ); + void onTap() => controller.setActiveSpacesEntry( + context, + space, + ); + final trailing = room != null + ? SizedBox( + width: 32, + child: IconButton( + splashRadius: 24, + icon: const Icon(Icons.edit_outlined), + tooltip: L10n.of(context)!.edit, + onPressed: () => controller.editSpace(context, room.id), + ), + ) + : const Icon(Icons.arrow_forward_ios_outlined); + + if (entry.children.isEmpty) { + return ListTile( + selected: active, + leading: leading, + title: title, + subtitle: subtitle, + onTap: onTap, + trailing: trailing, + ); + } else { + return ExpansionTile( + leading: leading, + initiallyExpanded: + entry.children.any((element) => entry.isActiveOfChild(controller)), + title: GestureDetector( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded(child: title), + const SizedBox(width: 8), + trailing + ]), + ), + children: entry.children + .map((e) => SpacesDrawerEntry(entry: e, controller: controller)) + .toList(), + ); + } + } +} diff --git a/lib/pages/chat_list/spaces_hierarchy_proposal.dart b/lib/pages/chat_list/spaces_hierarchy_proposal.dart new file mode 100644 index 00000000..d1ed9c74 --- /dev/null +++ b/lib/pages/chat_list/spaces_hierarchy_proposal.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:animations/animations.dart'; +import 'package:async/async.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_list/search_title.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'recommended_room_list_item.dart'; + +class SpacesHierarchyProposals extends StatefulWidget { + static final Map> _cache = {}; + + final String? space; + final String? query; + + const SpacesHierarchyProposals({ + Key? key, + required this.space, + this.query, + }) : super(key: key); + + @override + State createState() => + _SpacesHierarchyProposalsState(); +} + +class _SpacesHierarchyProposalsState extends State { + @override + void didUpdateWidget(covariant SpacesHierarchyProposals oldWidget) { + if (oldWidget.space != widget.space || oldWidget.query != widget.query) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + // check for recommended rooms in case the active space is a [SpaceSpacesEntry] + if (widget.space != null) { + final client = Matrix.of(context).client; + + final cache = SpacesHierarchyProposals._cache[widget.space!] ??= + AsyncCache(const Duration(minutes: 15)); + + /// additionally saving the future's state in the completer in order to + /// display the loading indicator when refreshing as a [FutureBuilder] is + /// a [StatefulWidget]. + final completer = Completer(); + final future = cache.fetch(() => client.getSpaceHierarchy( + widget.space!, + suggestedOnly: true, + maxDepth: 1, + )); + future.then(completer.complete); + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + Widget child; + if (snapshot.hasData) { + final rooms = snapshot.data!.rooms.where( + (element) => + element.roomId != widget.space && + // filtering in case a query is given + (widget.query != null + ? (element.name?.contains(widget.query!) ?? false) || + (element.topic?.contains(widget.query!) ?? false) + // in case not, just leave it... + : true) && + client.rooms + .any((knownRoom) => element.roomId != knownRoom.id), + ); + if (rooms.isEmpty) child = const ListTile(key: ValueKey(false)); + child = Column( + key: ValueKey(widget.space), + mainAxisSize: MainAxisSize.min, + children: [ + SearchTitle( + title: L10n.of(context)!.suggestedRooms, + icon: const Icon(Icons.auto_awesome_outlined), + trailing: completer.isCompleted + ? const Icon( + Icons.refresh_outlined, + size: 16, + ) + : const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + ), + ), + onTap: () => setState( + () => SpacesHierarchyProposals._cache[widget.space!]! + .invalidate(), + ), + ), + ...rooms.map( + (e) => RecommendedRoomListItem( + room: e, + ), + ), + ], + ); + } else { + child = Column( + key: const ValueKey(null), + children: const [ + LinearProgressIndicator(), + ListTile(), + ], + ); + } + return PageTransitionSwitcher( + // prevent the animation from re-building on dependency change + key: ValueKey(widget.space), + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.scaled, + child: child, + fillColor: Colors.transparent, + ); + }, + layoutBuilder: (children) => Stack( + alignment: Alignment.topCenter, + children: children, + ), + child: child, + ); + }, + ); + } else { + return Container(); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index ff9527dc..da687cfc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -72,7 +72,7 @@ packages: source: hosted version: "1.1.0" async: - dependency: transitive + dependency: "direct main" description: name: async url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 402e1b77..77173eba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ dependencies: adaptive_dialog: ^1.5.1 adaptive_theme: ^3.0.0 animations: ^2.0.2 + async: ^2.8.2 blurhash_dart: ^1.1.0 cached_network_image: ^3.2.0 callkeep: ^0.3.2 diff --git a/scripts/enable-android-google-services.patch b/scripts/enable-android-google-services.patch index 5b1a49ff..299ea1c5 100644 --- a/scripts/enable-android-google-services.patch +++ b/scripts/enable-android-google-services.patch @@ -157,7 +157,7 @@ diff --git a/pubspec.yaml b/pubspec.yaml index 6999d0b8..b2c9144f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml -@@ -27,7 +27,7 @@ dependencies: +@@ -28,7 +28,7 @@ dependencies: emoji_proposal: ^0.0.1 emojis: ^0.9.0 encrypt: ^5.0.1