feat: implement spaces hierarchy

- implement spaces hierarchy API
- display suggested rooms below room list
- allow joining suggested rooms

Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
TheOneWithTheBraid 2022-05-20 19:29:44 +02:00
parent 014c1574ee
commit 26484cc2bd
5 changed files with 539 additions and 58 deletions

View File

@ -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",

View File

@ -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<GetSpaceHierarchyResponse> 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);
}
}

View File

@ -0,0 +1,165 @@
import 'package:matrix/matrix.dart';
class SpaceRoomsChunkBase {
SpaceRoomsChunkBase({
required this.childrenState,
required this.roomType,
});
SpaceRoomsChunkBase.fromJson(Map<String, dynamic> json)
: childrenState = (json['children_state'] as List)
.map((v) => MatrixEvent.fromJson(v))
.toList(),
roomType = json['room_type'] as String;
Map<String, dynamic> 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<MatrixEvent> 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<String, dynamic> 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<String, dynamic>)
..putIfAbsent('event_id', () => 'invalid')))
.toList(),
roomType = json['room_type'] as String?;
@override
Map<String, dynamic> 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<MatrixEvent> 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<String>? aliases;
}
class GetSpaceHierarchyResponse {
GetSpaceHierarchyResponse({
this.nextBatch,
required this.rooms,
});
GetSpaceHierarchyResponse.fromJson(Map<String, dynamic> json)
: nextBatch = ((v) => v != null ? v as String : null)(json['next_batch']),
rooms = (json['rooms'] as List)
.map((v) => SpaceRoomsChunk.fromJson(v))
.toList();
Map<String, dynamic> 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<SpaceRoomsChunk> rooms;
}

View File

@ -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<ChatListViewBody> {
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: <Widget>[
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<GetSpaceHierarchyResponse?> 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<GetSpaceHierarchyResponse?>(
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: <Widget>[
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 =

View File

@ -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: <Widget>[
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: <Widget>[
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)),
)
],
),
),
),
),
),
);
}
}