diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index e4bcfcbd..ab158a2f 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2763,14 +2763,21 @@ "emailOrUsername": "Email or username", "@emailOrUsername": {}, "switchToAccount": "Switch to account {number}", - "@switchToAccount": { - "type": "number", - "placeholders": { - "number": {} - } - }, - "nextAccount": "Next account", - "previousAccount": "Previous account", + "@switchToAccount": { + "type": "number", + "placeholders": { + "number": {} + } + }, + "numberRoomMembers": "{number} members", + "@numberRoomMembers": { + "type": "number", + "placeholders": { + "number": {} + } + }, + "nextAccount": "Next account", + "previousAccount": "Previous account", "editWidgets": "Edit widgets", "addWidget": "Add widget", "widgetVideo": "Video", diff --git a/lib/msc/extension_spaces_advanced/spaces_advaced.dart b/lib/msc/extension_spaces_advanced/spaces_advaced.dart new file mode 100644 index 00000000..44298334 --- /dev/null +++ b/lib/msc/extension_spaces_advanced/spaces_advaced.dart @@ -0,0 +1,59 @@ +library spaces_advanced; + +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix_api_lite/matrix_api_lite.dart'; + +import 'src/space_hierarchy_model.dart'; + +extension SpacesAdvanced on MatrixApi { + /// Paginates over the space tree in a depth-first manner to locate child rooms of a given space. + /// + /// Where a child room is unknown to the local server, federation is used to fill in the details. + /// The servers listed in the `via` array should be contacted to attempt to fill in missing rooms. + /// + /// Only [`m.space.child`](#mspacechild) state events of the room are considered. Invalid child + /// rooms and parent events are not covered by this endpoint. + /// + /// [roomId] The room ID of the space to get a hierarchy for. + /// + /// [suggestedOnly] Optional (default `false`) flag to indicate whether or not the server should only consider + /// suggested rooms. Suggested rooms are annotated in their [`m.space.child`](#mspacechild) event + /// contents. + /// + /// [limit] Optional limit for the maximum number of rooms to include per response. Must be an integer + /// greater than zero. + /// + /// Servers should apply a default value, and impose a maximum value to avoid resource exhaustion. + /// + /// [maxDepth] Optional limit for how far to go into the space. Must be a non-negative integer. + /// + /// When reached, no further child rooms will be returned. + /// + /// Servers should apply a default value, and impose a maximum value to avoid resource exhaustion. + /// + /// [from] A pagination token from a previous result. If specified, `max_depth` and `suggested_only` cannot + /// be changed from the first request. + Future getSpaceHierarchy(String roomId, + {bool? suggestedOnly, int? limit, int? maxDepth, String? from}) async { + final requestUri = Uri( + path: + '_matrix/client/v1/rooms/${Uri.encodeComponent(roomId)}/hierarchy', + queryParameters: { + if (suggestedOnly != null) 'suggested_only': suggestedOnly.toString(), + if (limit != null) 'limit': limit.toString(), + if (maxDepth != null) 'max_depth': maxDepth.toString(), + if (from != null) 'from': from, + }); + final request = Request('GET', baseUri!.resolveUri(requestUri)); + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return GetSpaceHierarchyResponse.fromJson(json); + } +} diff --git a/lib/msc/extension_spaces_advanced/src/space_hierarchy_model.dart b/lib/msc/extension_spaces_advanced/src/space_hierarchy_model.dart new file mode 100644 index 00000000..d961ab86 --- /dev/null +++ b/lib/msc/extension_spaces_advanced/src/space_hierarchy_model.dart @@ -0,0 +1,165 @@ +import 'package:matrix/matrix.dart'; + +class SpaceRoomsChunkBase { + SpaceRoomsChunkBase({ + required this.childrenState, + required this.roomType, + }); + + SpaceRoomsChunkBase.fromJson(Map json) + : childrenState = (json['children_state'] as List) + .map((v) => MatrixEvent.fromJson(v)) + .toList(), + roomType = json['room_type'] as String; + + Map toJson() => { + 'children_state': childrenState.map((v) => v.toJson()).toList(), + 'room_type': roomType, + }; + + /// The [`m.space.child`](#mspacechild) events of the space-room, represented + /// as [Stripped State Events](#stripped-state) with an added `origin_server_ts` key. + /// + /// If the room is not a space-room, this should be empty. + List childrenState; + + /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. + String? roomType; +} + +class SpaceRoomsChunk implements PublicRoomsChunk, SpaceRoomsChunkBase { + SpaceRoomsChunk({ + this.avatarUrl, + this.canonicalAlias, + required this.guestCanJoin, + this.joinRule, + this.name, + required this.numJoinedMembers, + required this.roomId, + this.topic, + required this.worldReadable, + required this.childrenState, + required this.roomType, + }); + + SpaceRoomsChunk.fromJson(Map json) + : avatarUrl = + ((v) => v != null ? Uri.parse(v) : null)(json['avatar_url']), + canonicalAlias = + ((v) => v != null ? v as String : null)(json['canonical_alias']), + guestCanJoin = json['guest_can_join'] as bool, + joinRule = ((v) => v != null ? v as String : null)(json['join_rule']), + name = ((v) => v != null ? v as String : null)(json['name']), + numJoinedMembers = json['num_joined_members'] as int, + roomId = json['room_id'] as String, + topic = ((v) => v != null ? v as String : null)(json['topic']), + worldReadable = json['world_readable'] as bool, + childrenState = (json['children_state'] as List) + .map((v) => MatrixEvent.fromJson((v as Map) + ..putIfAbsent('event_id', () => 'invalid'))) + .toList(), + roomType = json['room_type'] as String?; + + @override + Map toJson() { + final avatarUrl = this.avatarUrl; + final canonicalAlias = this.canonicalAlias; + final joinRule = this.joinRule; + final name = this.name; + final topic = this.topic; + return { + if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), + if (canonicalAlias != null) 'canonical_alias': canonicalAlias, + 'guest_can_join': guestCanJoin, + if (joinRule != null) 'join_rule': joinRule, + if (name != null) 'name': name, + 'num_joined_members': numJoinedMembers, + 'room_id': roomId, + if (topic != null) 'topic': topic, + 'world_readable': worldReadable, + 'children_state': childrenState.map((v) => v.toJson()).toList(), + 'room_type': roomType, + }; + } + + /// The URL for the room's avatar, if one is set. + @override + Uri? avatarUrl; + + /// The canonical alias of the room, if any. + @override + String? canonicalAlias; + + /// Whether guest users may join the room and participate in it. + /// If they can, they will be subject to ordinary power level + /// rules like any other user. + @override + bool guestCanJoin; + + /// The room's join rule. When not present, the room is assumed to + /// be `public`. + @override + String? joinRule; + + /// The name of the room, if any. + @override + String? name; + + /// The number of members joined to the room. + @override + int numJoinedMembers; + + /// The ID of the room. + @override + String roomId; + + /// The topic of the room, if any. + @override + String? topic; + + /// Whether the room may be viewed by guest users without joining. + @override + bool worldReadable; + + /// The [`m.space.child`](#mspacechild) events of the space-room, represented + /// as [Stripped State Events](#stripped-state) with an added `origin_server_ts` key. + /// + /// If the room is not a space-room, this should be empty. + @override + List childrenState; + + /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. + @override + String? roomType; + + @override + List? aliases; +} + +class GetSpaceHierarchyResponse { + GetSpaceHierarchyResponse({ + this.nextBatch, + required this.rooms, + }); + + GetSpaceHierarchyResponse.fromJson(Map json) + : nextBatch = ((v) => v != null ? v as String : null)(json['next_batch']), + rooms = (json['rooms'] as List) + .map((v) => SpaceRoomsChunk.fromJson(v)) + .toList(); + + Map toJson() { + final nextBatch = this.nextBatch; + return { + if (nextBatch != null) 'next_batch': nextBatch, + 'rooms': rooms.map((v) => v.toJson()).toList(), + }; + } + + /// A token to supply to `from` to keep paginating the responses. Not present when there are + /// no further results. + String? nextBatch; + + /// The rooms for the current page, with the current filters. + List rooms; +} diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 438dd281..34bddd85 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -6,12 +6,16 @@ import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/msc/extension_spaces_advanced/spaces_advaced.dart'; +import 'package:fluffychat/msc/extension_spaces_advanced/src/space_hierarchy_model.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/spaces_bottom_bar.dart'; +import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/pages/chat_list/stories_header.dart'; import '../../utils/stream_extension.dart'; import '../../widgets/matrix.dart'; +import 'recommended_room_list_item.dart'; class ChatListViewBody extends StatefulWidget { final ChatListController controller; @@ -49,61 +53,137 @@ 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, - 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, - ), - ), - ), - ], - ); + + final displayStoriesHeader = + widget.controller.activeSpacesEntry.shouldShowStoriesHeader(context); + + final space = widget.controller.activeSpacesEntry.getSpace(context); + + Future hierarchyFuture; + bool skipFuture; + + // check for recommended rooms in case the active space is a [SpaceSpacesEntry] + if (widget.controller.activeSpacesEntry is SpaceSpacesEntry && + space != null) { + skipFuture = false; + hierarchyFuture = Matrix.of(context) + .client + .getSpaceHierarchy(space.id, suggestedOnly: true, maxDepth: 1); } else { - final displayStoriesHeader = widget.controller.activeSpacesEntry - .shouldShowStoriesHeader(context); - child = ListView.builder( + skipFuture = true; + hierarchyFuture = Future(() async => null); + } + + child = FutureBuilder( 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 const StoriesHeader(); - } - i--; - } - if (i >= rooms.length) { - return const ListTile(); - } - 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, + future: hierarchyFuture, + builder: (context, snapshot) { + final client = Matrix.of(context).client; + + int recommendedCount = snapshot.hasData + ? snapshot.data?.rooms + .where((element) => + client.rooms.indexWhere( + (room) => element.roomId == room.id) == + -1) + .length ?? + 0 + : 0; + + // adding space for separator + if (recommendedCount != 0) recommendedCount++; + + int joinedCount = rooms.length + (displayStoriesHeader ? 1 : 0); + if (rooms.isEmpty) joinedCount++; + + final count = joinedCount + recommendedCount + 1; + + return ListView.builder( + controller: widget.controller.scrollController, + // add +1 space below in order to properly scroll below the spaces bar + itemCount: count, + itemBuilder: (BuildContext context, int i) { + // first render the stories header + if (displayStoriesHeader && i == 0) { + return const StoriesHeader(); + } + // the room tiles afterwards + else if (i < joinedCount) { + // in case there are no joined rooms, display friendly graphics + if (rooms.isEmpty) { + return 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, + ), + ), + ), + ], + ); + } + final room = rooms[i - (displayStoriesHeader ? 1 : 0)]; + return ChatListItem( + room, + selected: + widget.controller.selectedRoomIds.contains(room.id), + onTap: widget.controller.selectMode == SelectMode.select + ? () => widget.controller.toggleSelection(room.id) + : null, + onLongPress: () => + widget.controller.toggleSelection(room.id), + activeChat: widget.controller.activeChat == room.id, + ); + // display a trailing empty list tile at last position to avoid overflow with bottom bar + } else if (i == count - 1) { + return skipFuture || snapshot.hasData + ? const ListTile() + : Column( + children: const [ + Center( + child: CircularProgressIndicator(), + ), + ListTile(), + ], + ); + // at the last position before the recommendations, show separator + } else if (i == joinedCount - (displayStoriesHeader ? 1 : 0)) { + return Column( + children: [ + const ListTile(), + const Divider(), + ListTile( + leading: const Icon(Icons.explore), + title: Text(L10n.of(context)!.discoverGroups)) + ], + ); + // only recommendation tiles left + } else { + final roomPreview = snapshot.data!.rooms + .where((element) => + client.rooms.indexWhere( + (room) => element.roomId == room.id) == + -1) + .toList()[i - joinedCount - 1]; + return RecommendedRoomListItem(room: roomPreview); + } + }, ); - }, - ); - } + }); } else { const dummyChatCount = 5; final titleColor = 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..4f0100e9 --- /dev/null +++ b/lib/pages/chat_list/recommended_room_list_item.dart @@ -0,0 +1,170 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:vrouter/vrouter.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/msc/extension_spaces_advanced/src/space_hierarchy_model.dart'; +import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/content_banner.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class RecommendedRoomListItem extends StatelessWidget { + final SpaceRoomsChunk room; + + const RecommendedRoomListItem({Key? key, required this.room}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: ListTile( + leading: Avatar( + mxContent: room.avatarUrl, + name: room.name, + ), + 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), + 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, + ), + ), + ), + ], + ), + 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, + ), + ), + ), + ], + ), + onTap: () => showModalBottomSheet( + context: context, + builder: (c) => RecommendedRoomPreview(room: room)), + ), + ); + } +} + +class RecommendedRoomPreview extends StatelessWidget { + final SpaceRoomsChunk room; + + const RecommendedRoomPreview({Key? key, required this.room}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + return Center( + child: SizedBox( + width: min( + MediaQuery.of(context).size.width, FluffyThemes.columnWidth * 1.5), + child: Material( + elevation: 4, + child: SafeArea( + child: Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + elevation: 0, + backgroundColor: + Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5), + leading: IconButton( + icon: const Icon(Icons.arrow_downward_outlined), + onPressed: Navigator.of(context, rootNavigator: false).pop, + tooltip: L10n.of(context)!.close, + ), + title: Text(room.name ?? L10n.of(context)!.chatDetails), + ), + body: Column( + children: [ + Expanded( + child: ContentBanner( + mxContent: room.avatarUrl, + defaultIcon: Icons.group, + client: client, + ), + ), + if (room.topic != null) + ListTile( + title: Text(L10n.of(context)!.groupDescription), + subtitle: Text(room.topic!), + ), + ListTile( + title: Text(L10n.of(context)!.link), + subtitle: Text(room.roomId), + trailing: Icon(Icons.adaptive.share_outlined), + onTap: () => FluffyShare.share(room.roomId, context), + ), + ListTile( + title: ElevatedButton( + onPressed: () async { + final client = Matrix.of(context).client; + final joinedFuture = client.onSync.stream + .where((u) => + u.rooms?.join?.containsKey(room.roomId) ?? + false) + .first; + final router = VRouter.of(context); + await client.joinRoomById(room.roomId); + + await showDialog( + context: context, + builder: (c) => + LoadingDialog(future: () => joinedFuture)); + Navigator.of(context).pop(); + router.toSegments(['rooms', room.roomId]); + }, + child: Text(L10n.of(context)!.joinRoom)), + ) + ], + ), + ), + ), + ), + ), + ); + } +}