From a522a90e3501fd90e6cfdc72ac98d28e461ffcda Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Sun, 24 Jul 2022 19:02:14 +0200 Subject: [PATCH] fix: Follow up for spaces hierarchy - fix endless spinner - fix rooms shon twice - fix spaces accidentally opened as rooms - add missing spaces header to spaces view Signed-off-by: TheOneWithTheBraid --- lib/main.dart | 4 ++ lib/pages/chat_list/chat_list.dart | 35 +++++++++-- lib/pages/chat_list/chat_list_body.dart | 62 ++++++++++++++++++- .../chat_list/recommended_room_list_item.dart | 44 +++++++------ lib/pages/chat_list/spaces_drawer_entry.dart | 10 +-- lib/pages/chat_list/spaces_entry.dart | 26 ++++++++ .../chat_list/spaces_hierarchy_proposal.dart | 16 ++--- lib/utils/space_navigator.dart | 23 +++++++ lib/utils/url_launcher.dart | 7 +++ lib/widgets/public_room_bottom_sheet.dart | 57 +++++++++++++---- 10 files changed, 234 insertions(+), 50 deletions(-) create mode 100644 lib/utils/space_navigator.dart diff --git a/lib/main.dart b/lib/main.dart index 6d061856..dd3b3bd1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'config/themes.dart'; import 'utils/background_push.dart'; import 'utils/custom_scroll_behaviour.dart'; import 'utils/localized_exception_extension.dart'; +import 'utils/space_navigator.dart'; import 'widgets/lock_screen.dart'; import 'widgets/matrix.dart'; @@ -132,6 +133,9 @@ class _FluffyChatAppState extends State { localizationsDelegates: const [ ...L10n.localizationsDelegates, ], + navigatorObservers: [ + SpaceNavigator.routeObserver, + ], supportedLocales: L10n.supportedLocales, initialUrl: _initialUrl ?? '/', routes: AppRoutes(columnMode ?? false).routes, diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index ba2de49f..b4787e97 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -18,6 +18,7 @@ import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/utils/famedlysdk_store.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/space_navigator.dart'; import '../../../utils/account_bundles.dart'; import '../../main.dart'; import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; @@ -47,7 +48,8 @@ class ChatList extends StatefulWidget { ChatListController createState() => ChatListController(); } -class ChatListController extends State with TickerProviderStateMixin { +class ChatListController extends State + with TickerProviderStateMixin, RouteAware { StreamSubscription? _intentDataStreamSubscription; StreamSubscription? _intentFileStreamSubscription; @@ -66,6 +68,8 @@ class ChatListController extends State with TickerProviderStateMixin { bool isSearching = false; static const String _serverStoreNamespace = 'im.fluffychat.search.server'; + StreamSubscription? _spacesSubscription; + void setServer() async { final newServer = await showTextInputDialog( useRootNavigator: false, @@ -177,11 +181,6 @@ class ChatListController extends State with TickerProviderStateMixin { } } - void setActiveSpacesEntry(BuildContext context, SpacesEntry? spaceId) { - Scaffold.of(context).closeDrawer(); - setState(() => _activeSpacesEntry = spaceId); - } - void editSpace(BuildContext context, String spaceId) async { await Matrix.of(context).client.getRoomById(spaceId)!.postLoad(); VRouter.of(context).toSegments(['spaces', spaceId]); @@ -310,6 +309,8 @@ class ChatListController extends State with TickerProviderStateMixin { _checkTorBrowser(); + _subscribeSpaceChanges(); + super.initState(); } @@ -336,6 +337,7 @@ class ChatListController extends State with TickerProviderStateMixin { _intentDataStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel(); _intentUriStreamSubscription?.cancel(); + _spacesSubscription?.cancel(); scrollController.removeListener(_onScroll); super.dispose(); } @@ -671,6 +673,27 @@ class ChatListController extends State with TickerProviderStateMixin { Future dehydrate() => SettingsAccountController.dehydrateDevice(context); + + _adjustSpaceQuery(String? spaceId) { + cancelSearch(); + setState(() { + if (spaceId != null) { + final matching = + spacesEntries.where((element) => element.routeHandle == spaceId); + if (matching.isNotEmpty) { + _activeSpacesEntry = matching.first; + } else { + _activeSpacesEntry = defaultSpacesEntry; + } + } else { + _activeSpacesEntry = defaultSpacesEntry; + } + }); + } + + void _subscribeSpaceChanges() { + _spacesSubscription = SpaceNavigator.stream.listen(_adjustSpaceQuery); + } } enum EditBundleAction { addToBundle, removeFromBundle } diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 68158210..71b295e5 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -5,12 +5,14 @@ import 'package:flutter/material.dart'; import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix_link_text/link_text.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/utils/url_launcher.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; @@ -75,6 +77,7 @@ class _ChatListViewBodyState extends State { mainAxisSize: MainAxisSize.min, children: [ const ConnectionStatusHeader(), + SpaceRoomListTopBar(widget.controller), if (roomSearchResult != null) ...[ SearchTitle( title: L10n.of(context)!.publicRooms, @@ -205,7 +208,7 @@ class _ChatListViewBodyState extends State { title: L10n.of(context)!.chats, icon: const Icon(Icons.chat_outlined), ), - if (rooms.isEmpty) + if (rooms.isEmpty && !widget.controller.isSearchMode) Column( key: const ValueKey(null), mainAxisAlignment: MainAxisAlignment.center, @@ -366,6 +369,63 @@ class _ChatListViewBodyState extends State { _lastSpace = widget.controller.activeSpacesEntry; return reversed; } + + @override + void didUpdateWidget(covariant ChatListViewBody oldWidget) { + setState(() {}); + super.didUpdateWidget(oldWidget); + } +} + +class SpaceRoomListTopBar extends StatefulWidget { + final ChatListController controller; + + const SpaceRoomListTopBar(this.controller, {Key? key}) : super(key: key); + + @override + State createState() => _SpaceRoomListTopBarState(); +} + +class _SpaceRoomListTopBarState extends State { + bool _limitSize = true; + + @override + Widget build(BuildContext context) { + if (widget.controller.activeSpacesEntry is SpaceSpacesEntry && + !widget.controller.isSearchMode && + (widget.controller.activeSpacesEntry as SpaceSpacesEntry) + .space + .topic + .isNotEmpty) { + return GestureDetector( + onTap: () => setState(() { + _limitSize = !_limitSize; + }), + child: Column( + children: [ + Padding( + child: LinkText( + text: (widget.controller.activeSpacesEntry as SpaceSpacesEntry) + .space + .topic, + maxLines: _limitSize ? 3 : null, + linkStyle: const TextStyle(color: Colors.blueAccent), + textStyle: TextStyle( + fontSize: 14, + color: Theme.of(context).textTheme.bodyText2!.color, + ), + onLinkTap: (url) => UrlLauncher(context, url).launchUrl(), + ), + padding: const EdgeInsets.all(8), + ), + const Divider(), + ], + ), + ); + } else { + return Container(); + } + } } class _SearchItem extends StatelessWidget { diff --git a/lib/pages/chat_list/recommended_room_list_item.dart b/lib/pages/chat_list/recommended_room_list_item.dart index d315ad5d..210bd822 100644 --- a/lib/pages/chat_list/recommended_room_list_item.dart +++ b/lib/pages/chat_list/recommended_room_list_item.dart @@ -9,9 +9,13 @@ import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; class RecommendedRoomListItem extends StatelessWidget { final SpaceRoomsChunk room; + final VoidCallback onRoomJoined; - const RecommendedRoomListItem({Key? key, required this.room}) - : super(key: key); + const RecommendedRoomListItem({ + Key? key, + required this.room, + required this.onRoomJoined, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -59,28 +63,30 @@ class RecommendedRoomListItem extends StatelessWidget { ), ], ); - 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, - ), - ), - ), - ], - ); + final subtitle = room.topic != null + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + room.topic!, + softWrap: false, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).textTheme.bodyText2!.color, + ), + ), + ), + ], + ) + : null; void handler() => showModalBottomSheet( context: context, builder: (c) => PublicRoomBottomSheet( - roomAlias: room.canonicalAlias!, outerContext: context, chunk: room, + onRoomJoined: onRoomJoined, ), ); if (room.roomType == 'm.space') { diff --git a/lib/pages/chat_list/spaces_drawer_entry.dart b/lib/pages/chat_list/spaces_drawer_entry.dart index d5e3f3bd..87d06ec7 100644 --- a/lib/pages/chat_list/spaces_drawer_entry.dart +++ b/lib/pages/chat_list/spaces_drawer_entry.dart @@ -4,6 +4,7 @@ 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/utils/space_navigator.dart'; import 'package:fluffychat/widgets/avatar.dart'; class SpacesDrawerEntry extends StatelessWidget { @@ -46,10 +47,11 @@ class SpacesDrawerEntry extends StatelessWidget { overflow: TextOverflow.fade, ), ); - void onTap() => controller.setActiveSpacesEntry( - context, - space, - ); + void onTap() { + SpaceNavigator.navigateToSpace(space.routeHandle); + Scaffold.of(context).closeDrawer(); + } + final trailing = room != null ? SizedBox( width: 32, diff --git a/lib/pages/chat_list/spaces_entry.dart b/lib/pages/chat_list/spaces_entry.dart index b9e064c2..bda07355 100644 --- a/lib/pages/chat_list/spaces_entry.dart +++ b/lib/pages/chat_list/spaces_entry.dart @@ -19,18 +19,25 @@ abstract class 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; + + String? get routeHandle; } // Common room validity checks @@ -58,7 +65,9 @@ bool _roomInsideSpace(Room room, Room space) { // "All rooms" entry. class AllRoomsSpacesEntry extends SpacesEntry { static final AllRoomsSpacesEntry _value = AllRoomsSpacesEntry._(); + AllRoomsSpacesEntry._(); + factory AllRoomsSpacesEntry() { return _value; } @@ -75,6 +84,9 @@ class AllRoomsSpacesEntry extends SpacesEntry { .toList(); } + @override + final String? routeHandle = null; + @override bool shouldShowStoriesHeader(BuildContext context) => true; @@ -90,7 +102,9 @@ class AllRoomsSpacesEntry extends SpacesEntry { // "Direct Chats" entry. class DirectChatsSpacesEntry extends SpacesEntry { static final DirectChatsSpacesEntry _value = DirectChatsSpacesEntry._(); + DirectChatsSpacesEntry._(); + factory DirectChatsSpacesEntry() { return _value; } @@ -107,6 +121,9 @@ class DirectChatsSpacesEntry extends SpacesEntry { .toList(); } + @override + final String? routeHandle = null; + @override bool shouldShowStoriesHeader(BuildContext context) => true; @@ -122,7 +139,9 @@ class DirectChatsSpacesEntry extends SpacesEntry { // "Groups" entry. class GroupsSpacesEntry extends SpacesEntry { static final GroupsSpacesEntry _value = GroupsSpacesEntry._(); + GroupsSpacesEntry._(); + factory GroupsSpacesEntry() { return _value; } @@ -147,6 +166,9 @@ class GroupsSpacesEntry extends SpacesEntry { .toList(); } + @override + final String? routeHandle = 'groups'; + bool separatedGroup(Room room, List spaces) { return !spaces.any((space) => _roomInsideSpace(room, space)); } @@ -163,6 +185,7 @@ class GroupsSpacesEntry extends SpacesEntry { // All rooms associated with a specific space. class SpaceSpacesEntry extends SpacesEntry { final Room space; + const SpaceSpacesEntry(this.space); @override @@ -204,6 +227,9 @@ class SpaceSpacesEntry extends SpacesEntry { bool stillValid(BuildContext context) => Matrix.of(context).client.getRoomById(space.id) != null; + @override + String? get routeHandle => space.id; + @override bool operator ==(Object other) { return hashCode == other.hashCode; diff --git a/lib/pages/chat_list/spaces_hierarchy_proposal.dart b/lib/pages/chat_list/spaces_hierarchy_proposal.dart index d1ed9c74..cccfdd80 100644 --- a/lib/pages/chat_list/spaces_hierarchy_proposal.dart +++ b/lib/pages/chat_list/spaces_hierarchy_proposal.dart @@ -94,14 +94,12 @@ class _SpacesHierarchyProposalsState extends State { strokeWidth: 1, ), ), - onTap: () => setState( - () => SpacesHierarchyProposals._cache[widget.space!]! - .invalidate(), - ), + onTap: _refreshRooms, ), ...rooms.map( (e) => RecommendedRoomListItem( room: e, + onRoomJoined: _refreshRooms, ), ), ], @@ -109,9 +107,9 @@ class _SpacesHierarchyProposalsState extends State { } else { child = Column( key: const ValueKey(null), - children: const [ - LinearProgressIndicator(), - ListTile(), + children: [ + if (!snapshot.hasError) const LinearProgressIndicator(), + const ListTile(), ], ); } @@ -143,4 +141,8 @@ class _SpacesHierarchyProposalsState extends State { return Container(); } } + + void _refreshRooms() => setState( + () => SpacesHierarchyProposals._cache[widget.space!]!.invalidate(), + ); } diff --git a/lib/utils/space_navigator.dart b/lib/utils/space_navigator.dart new file mode 100644 index 00000000..af72a0b2 --- /dev/null +++ b/lib/utils/space_navigator.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// this is a workaround to allow navigation of spaces out from any widget. +/// Reason is that we have no reliable way to listen on *query* changes of +/// VRouter. +/// +/// Time wasted: 3h +abstract class SpaceNavigator { + const SpaceNavigator._(); + + // TODO(TheOneWithTheBraid): adjust routing table in order to represent spaces + // ... in any present path + static final routeObserver = RouteObserver(); + + static final StreamController _controller = + StreamController.broadcast(); + + static Stream get stream => _controller.stream; + + static void navigateToSpace(String? spaceId) => _controller.add(spaceId); +} diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 3d60efbd..1b9284d2 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/space_navigator.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; @@ -18,6 +19,7 @@ import 'platform_infos.dart'; class UrlLauncher { final String? url; final BuildContext context; + const UrlLauncher(this.context, this.url); void launchUrl() { @@ -130,6 +132,11 @@ class UrlLauncher { } servers.addAll(identityParts.via); if (room != null) { + if (room.isSpace) { + SpaceNavigator.navigateToSpace(room.id); + VRouter.of(context).toSegments(['rooms']); + return; + } // we have the room, so....just open it if (event != null) { VRouter.of(context).toSegments(['rooms', room.id], diff --git a/lib/widgets/public_room_bottom_sheet.dart b/lib/widgets/public_room_bottom_sheet.dart index 40b9d554..f5d483f2 100644 --- a/lib/widgets/public_room_bottom_sheet.dart +++ b/lib/widgets/public_room_bottom_sheet.dart @@ -3,39 +3,48 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_matrix_html/flutter_html.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix_link_text/link_text.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/content_banner.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../utils/localized_exception_extension.dart'; class PublicRoomBottomSheet extends StatelessWidget { - final String roomAlias; + final String? roomAlias; final BuildContext outerContext; final PublicRoomsChunk? chunk; - const PublicRoomBottomSheet({ - required this.roomAlias, + final VoidCallback? onRoomJoined; + + PublicRoomBottomSheet({ + this.roomAlias, required this.outerContext, this.chunk, + this.onRoomJoined, Key? key, - }) : super(key: key); + }) : super(key: key) { + assert(roomAlias != null || chunk != null); + } void _joinRoom(BuildContext context) async { final client = Matrix.of(context).client; final result = await showFutureLoadingDialog( context: context, - future: () => client.joinRoom(roomAlias), + future: () => client.joinRoom(roomAlias ?? chunk!.roomId), ); if (result.error == null) { if (client.getRoomById(result.result!) == null) { await client.onSync.stream.firstWhere( (sync) => sync.rooms?.join?.containsKey(result.result) ?? false); } - VRouter.of(context).toSegments(['rooms', result.result!]); + // don't open the room if the joined room is a space + if (!client.getRoomById(result.result!)!.isSpace) { + VRouter.of(context).toSegments(['rooms', result.result!]); + } Navigator.of(context, rootNavigator: false).pop(); return; } @@ -47,7 +56,7 @@ class PublicRoomBottomSheet extends StatelessWidget { final chunk = this.chunk; if (chunk != null) return chunk; final query = await Matrix.of(context).client.queryPublicRooms( - server: roomAlias.domain, + server: roomAlias!.domain, filter: PublicRoomQueryFilter( genericSearchTerm: roomAlias, ), @@ -76,7 +85,7 @@ class PublicRoomBottomSheet extends StatelessWidget { backgroundColor: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5), title: Text( - roomAlias, + roomAlias ?? chunk!.name ?? chunk!.roomId, overflow: TextOverflow.fade, ), leading: IconButton( @@ -118,15 +127,37 @@ class PublicRoomBottomSheet extends StatelessWidget { client: Matrix.of(context).client, ), ListTile( - title: - Text(profile?.name ?? roomAlias.localpart ?? ''), + title: Text(profile?.name ?? + roomAlias?.localpart ?? + chunk!.roomId.localpart ?? + ''), subtitle: Text( - '${L10n.of(context)!.participant}: ${profile?.numJoinedMembers ?? 0}'), + '${L10n.of(context)!.participant}: ${profile?.numJoinedMembers ?? 0}', + ), trailing: const Icon(Icons.account_box_outlined), ), if (profile?.topic?.isNotEmpty ?? false) ListTile( - subtitle: Html(data: profile!.topic!), + title: Text( + L10n.of(context)!.groupDescription, + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + ), + ), + subtitle: LinkText( + text: profile!.topic!, + linkStyle: + const TextStyle(color: Colors.blueAccent), + textStyle: TextStyle( + fontSize: 14, + color: Theme.of(context) + .textTheme + .bodyText2! + .color, + ), + onLinkTap: (url) => + UrlLauncher(context, url).launchUrl(), + ), ), ], );