You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/course_chats/course_chats_page.dart

842 lines
26 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_view.dart';
import 'package:fluffychat/pangea/course_chats/extended_space_rooms_chunk.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseChats extends StatefulWidget {
final Client client;
final String roomId;
final String? activeChat;
const CourseChats(
this.roomId, {
super.key,
required this.activeChat,
required this.client,
});
@override
State<CourseChats> createState() => CourseChatsController();
}
class CourseChatsController extends State<CourseChats> {
String get roomId => widget.roomId;
Room? get room => widget.client.getRoomById(widget.roomId);
List<SpaceRoomsChunk>? discoveredChildren;
StreamSubscription? _roomSubscription;
String? _nextBatch;
bool noMoreRooms = false;
bool isLoading = false;
CoursePlanModel? course;
@override
void initState() {
loadHierarchy(reload: true);
// Listen for changes to the activeSpace's hierarchy,
// and reload the hierarchy when they come through
_roomSubscription?.cancel();
_roomSubscription = widget.client.onSync.stream
.where(_hasHierarchyUpdate)
.listen((update) => loadHierarchy(reload: true));
super.initState();
}
@override
void didUpdateWidget(covariant CourseChats oldWidget) {
// initState doesn't re-run when navigating between spaces
// via the navigation rail, so this accounts for that
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
_roomSubscription?.cancel();
_roomSubscription = widget.client.onSync.stream
.where(_hasHierarchyUpdate)
.listen((update) => loadHierarchy(reload: true));
discoveredChildren = null;
_nextBatch = null;
noMoreRooms = false;
loadHierarchy(reload: true);
}
}
@override
void dispose() {
_roomSubscription?.cancel();
super.dispose();
}
void setCourse(CoursePlanModel? course) {
setState(() {
this.course = course;
});
}
Set<String> get childrenIds =>
room?.spaceChildren.map((c) => c.roomId).whereType<String>().toSet() ??
{};
List<Room> get joinedRooms => Matrix.of(context)
.client
.rooms
.where((room) => childrenIds.contains(room.id))
.where((room) => !room.isHiddenRoom)
.toList();
List<Room> joinedActivities() =>
joinedRooms.where((r) => r.isActivitySession).toList();
List<SpaceRoomsChunk> get discoveredGroupChats => (discoveredChildren ?? [])
.where(
(chunk) =>
chunk.roomType == null ||
!chunk.roomType!.startsWith(PangeaRoomTypes.activitySession),
)
.toList();
Map<ActivityPlanModel, List<ExtendedSpaceRoomsChunk>> discoveredActivities() {
if (discoveredChildren == null) return {};
final courseStates = room?.allCourseUserStates ?? {};
final Map<String, List<String>> roomsToUsers = {};
if (courseStates.isNotEmpty) {
for (final state in courseStates.values) {
final userID = state.userID;
for (final roomId in state.joinedActivityRooms) {
roomsToUsers[roomId] ??= [];
roomsToUsers[roomId]!.add(userID);
}
}
}
final Map<ActivityPlanModel, List<ExtendedSpaceRoomsChunk>> sessionsMap =
{};
for (final chunk in discoveredChildren!) {
if (chunk.roomType?.startsWith(PangeaRoomTypes.activitySession) != true) {
continue;
}
final activityId = chunk.roomType!.split(":").last;
final activity = course?.activityById(activityId);
if (activity == null) {
continue;
}
final users = roomsToUsers[chunk.roomId];
if (users != null && activity.req.numberOfParticipants <= users.length) {
// Don't show full activities
continue;
}
sessionsMap[activity] ??= [];
sessionsMap[activity]!.add(
ExtendedSpaceRoomsChunk(
chunk: chunk,
activityId: activityId,
userIds: users ?? [],
),
);
}
return sessionsMap;
}
List<Room> get joinedChats =>
joinedRooms.where((room) => !room.isActivitySession).toList();
Future<void> _joinDefaultChats() async {
if (discoveredChildren == null) return;
final found = List<SpaceRoomsChunk>.from(discoveredChildren!);
final List<Future> joinFutures = [];
for (final chunk in found) {
if (chunk.canonicalAlias == null) continue;
final alias = chunk.canonicalAlias!;
final isDefaultChat = (alias.localpart ?? '')
.startsWith(SpaceConstants.announcementsChatAlias) ||
(alias.localpart ?? '')
.startsWith(SpaceConstants.introductionChatAlias);
if (!isDefaultChat) continue;
joinFutures.add(
widget.client.joinRoom(alias).then((_) {
discoveredChildren?.remove(chunk);
}).catchError((e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'alias': alias,
'spaceId': widget.roomId,
},
);
return null;
}),
);
}
if (joinFutures.isNotEmpty) {
await Future.wait(joinFutures);
}
}
Future<void> loadHierarchy({reload = false}) async {
final room = widget.client.getRoomById(widget.roomId);
if (room == null) return;
if (mounted) setState(() => isLoading = true);
try {
await _loadHierarchy(activeSpace: room, reload: reload);
await _joinDefaultChats();
} catch (e, s) {
Logs().w('Unable to load hierarchy', e, s);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toLocalizedString(context))),
);
}
} finally {
if (mounted) {
setState(() => isLoading = false);
}
}
}
/// Internal logic of loadHierarchy. It will load the hierarchy of
/// the active space id (or specified spaceId).
/// If [reload] is true, it will reload the entire hierarchy (used when room
/// is added/removed from the space)
/// If [reload] is false, it will load the next set of rooms
Future<void> _loadHierarchy({
required Room activeSpace,
bool reload = false,
}) async {
// Load all of the space's state events. Space Child events
// are used to filtering out unsuggested, unjoined rooms.
await activeSpace.postLoad();
// The current number of rooms loaded for this space that are visible in the UI
final int prevLength = !reload ? (discoveredChildren?.length ?? 0) : 0;
// Failsafe to prevent too many calls to the server in a row
int callsToServer = 0;
List<SpaceRoomsChunk>? currentHierarchy =
discoveredChildren == null || reload
? null
: List.from(discoveredChildren!);
String? currentNextBatch = reload ? null : _nextBatch;
// Makes repeated calls to the server until 10 new visible rooms have
// been loaded, or there are no rooms left to load. Using a loop here,
// rather than one single call to the endpoint, because some spaces have
// so many invisible rooms (analytics rooms) that it might look like
// pressing the 'load more' button does nothing (Because the only rooms
// coming through from those calls are analytics rooms).
while (callsToServer < 5) {
// if this space has been loaded and there are no more rooms to load, break
if (currentHierarchy != null && currentNextBatch == null) {
break;
}
// if this space has been loaded and 10 new rooms have been loaded, break
final int currentLength = currentHierarchy?.length ?? 0;
if (currentLength - prevLength >= 10) {
break;
}
// make the call to the server
final response = await widget.client.getSpaceHierarchy(
widget.roomId,
maxDepth: 1,
from: currentNextBatch,
limit: 100,
);
callsToServer++;
if (response.nextBatch == null) {
noMoreRooms = true;
}
// if rooms have earlier been loaded for this space, add those
// previously loaded rooms to the front of the response list
response.rooms.insertAll(
0,
currentHierarchy ?? [],
);
// finally, set the response to the last response for this space
// and set the current next batch token
currentHierarchy = _filterHierarchyResponse(activeSpace, response.rooms);
currentNextBatch = response.nextBatch;
}
discoveredChildren = currentHierarchy;
discoveredChildren?.sort(_sortSpaceChildren);
_nextBatch = currentNextBatch;
}
void onChatTap(Room room) async {
if (room.membership == Membership.invite) {
final theme = Theme.of(context);
final inviteEvent = room.getState(
EventTypes.RoomMember,
room.client.userID!,
);
final matrixLocals = MatrixLocals(L10n.of(context));
final action = await showAdaptiveDialog<InviteAction>(
barrierDismissible: true,
context: context,
builder: (context) => AlertDialog.adaptive(
title: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Center(
child: Text(
room.getLocalizedDisplayname(matrixLocals),
textAlign: TextAlign.center,
),
),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256),
child: Text(
inviteEvent == null
? L10n.of(context).inviteForMe
: inviteEvent.content.tryGet<String>('reason') ??
L10n.of(context).youInvitedBy(
room
.unsafeGetUserFromMemoryOrFallback(
inviteEvent.senderId,
)
.calcDisplayname(i18n: matrixLocals),
),
textAlign: TextAlign.center,
),
),
actions: [
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.accept),
bigButtons: true,
child: Text(L10n.of(context).accept),
),
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.decline),
bigButtons: true,
child: Text(
L10n.of(context).decline,
style: TextStyle(color: theme.colorScheme.error),
),
),
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.block),
bigButtons: true,
child: Text(
L10n.of(context).block,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
),
);
switch (action) {
case null:
return;
case InviteAction.accept:
break;
case InviteAction.decline:
await showFutureLoadingDialog(
context: context,
future: () => room.leave(),
);
return;
case InviteAction.block:
final userId = inviteEvent?.senderId;
context.go('/rooms/settings/security/ignorelist', extra: userId);
return;
}
if (!mounted) return;
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
exceptionContext: ExceptionContext.joinRoom,
);
if (joinResult.error != null) return;
}
if (room.membership == Membership.ban) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).youHaveBeenBannedFromThisChat),
),
);
return;
}
if (room.membership == Membership.leave) {
context.go('/rooms/archive/${room.id}');
return;
}
if (room.isSpace) {
context.go("/rooms/spaces/${room.id}/details");
return;
}
context.go('/rooms/${room.id}');
}
void joinChildRoom(SpaceRoomsChunk item) async {
final space = widget.client.getRoomById(widget.roomId);
final joined = await PublicRoomBottomSheet.show(
context: context,
chunk: item,
via: space?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == item.roomId,
)
?.via,
);
if (mounted && joined == true) {
setState(() {
discoveredChildren?.remove(item);
});
}
}
void chatContextAction(
Room room,
BuildContext posContext, [
Room? space,
]) async {
final overlay =
Overlay.of(posContext).context.findRenderObject() as RenderBox;
final button = posContext.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
ancestor: overlay,
),
),
Offset.zero & overlay.size,
);
final displayname =
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)));
final action = await showMenu<ChatContextAction>(
context: posContext,
position: position,
items: [
PopupMenuItem(
value: ChatContextAction.open,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 12.0,
children: [
Avatar(
mxContent: room.avatar,
name: displayname,
userId: room.directChatMatrixID,
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 128),
child: Text(
displayname,
style:
TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const PopupMenuDivider(),
if (space != null)
PopupMenuItem(
value: ChatContextAction.goToSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
mxContent: space.avatar,
size: Avatar.defaultSize / 2,
name: space.getLocalizedDisplayname(),
userId: space.directChatMatrixID,
),
const SizedBox(width: 12),
Expanded(
child: Text(
L10n.of(context)
.goToCourse(space.getLocalizedDisplayname()),
),
),
],
),
),
if (room.membership == Membership.join) ...[
PopupMenuItem(
value: ChatContextAction.mute,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.pushRuleState == PushRuleState.notify
? Icons.notifications_on_outlined
: Icons.notifications_off_outlined,
),
const SizedBox(width: 12),
Text(
room.pushRuleState == PushRuleState.notify
? L10n.of(context).notificationsOn
: L10n.of(context).notificationsOff,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.markUnread,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.markedUnread
? Icons.mark_as_unread
: Icons.mark_as_unread_outlined,
),
const SizedBox(width: 12),
Text(
room.markedUnread
? L10n.of(context).markAsRead
: L10n.of(context).markAsUnread,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.favorite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
),
const SizedBox(width: 12),
Text(
room.isFavourite
? L10n.of(context).unpin
: L10n.of(context).pin,
),
],
),
),
],
PopupMenuItem(
value: ChatContextAction.leave,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.logout_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Text(
room.membership == Membership.invite
? L10n.of(context).delete
: L10n.of(context).leave,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
if (room.isRoomAdmin && !room.isDirectChat)
PopupMenuItem(
value: ChatContextAction.delete,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Text(
L10n.of(context).delete,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
],
);
if (action == null) return;
if (!mounted) return;
switch (action) {
case ChatContextAction.open:
onChatTap(room);
return;
case ChatContextAction.goToSpace:
context.go("/rooms/spaces/${space!.id}/details");
return;
case ChatContextAction.favorite:
await showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
return;
case ChatContextAction.markUnread:
await showFutureLoadingDialog(
context: context,
future: () => room.markUnread(!room.markedUnread),
);
return;
case ChatContextAction.mute:
await showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
),
);
return;
case ChatContextAction.leave:
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
message: room.isSpace
? L10n.of(context).leaveSpaceDescription
: L10n.of(context).leaveRoomDescription,
okLabel: L10n.of(context).leave,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
if (!mounted) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.isSpace ? room.leaveSpace : room.leave,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${widget.roomId}/details");
}
return;
case ChatContextAction.delete:
if (room.isSpace) {
final resp = await showDialog<bool?>(
context: context,
builder: (_) => DeleteSpaceDialog(space: room),
);
if (resp == true && mounted) {
context.go("/rooms");
}
} else {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).delete,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
message: room.isSpace
? L10n.of(context).deleteSpaceDesc
: L10n.of(context).deleteChatDesc,
);
if (confirmed != OkCancelResult.ok) return;
if (!mounted) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.delete,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${widget.roomId}/details");
}
}
return;
}
}
bool _includeSpaceChild(
Room space,
SpaceRoomsChunk hierarchyMember,
) {
if (!mounted) return false;
final bool isAnalyticsRoom =
hierarchyMember.roomType == PangeaRoomTypes.analytics;
final bool isMember = [Membership.join, Membership.invite].contains(
widget.client.getRoomById(hierarchyMember.roomId)?.membership,
);
final bool isSuggested =
space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true;
return !isAnalyticsRoom && (isMember || isSuggested);
}
List<SpaceRoomsChunk> _filterHierarchyResponse(
Room space,
List<SpaceRoomsChunk> hierarchyResponse,
) {
final List<SpaceRoomsChunk> filteredChildren = [];
for (final child in hierarchyResponse) {
if (child.roomId == widget.roomId) {
continue;
}
final room = space.client.getRoomById(child.roomId);
if (room != null && room.membership != Membership.leave) {
// If the room is already joined or invited, skip it
continue;
}
final isDuplicate = filteredChildren.any(
(filtered) => filtered.roomId == child.roomId,
);
if (isDuplicate) continue;
if (_includeSpaceChild(space, child)) {
filteredChildren.add(child);
}
}
return filteredChildren;
}
/// Used to filter out sync updates with hierarchy updates for the active
/// space so that the view can be auto-reloaded in the room subscription
bool _hasHierarchyUpdate(SyncUpdate update) {
final joinUpdate = update.rooms?.join;
final inviteUpdate = update.rooms?.invite;
final leaveUpdate = update.rooms?.leave;
if (joinUpdate == null && leaveUpdate == null && inviteUpdate == null) {
return false;
}
final joinedRooms = joinUpdate?.entries
.where(
(e) => childrenIds.contains(e.key),
)
.map((e) => e.value.timeline?.events)
.whereType<List<MatrixEvent>>();
final invitedRooms = inviteUpdate?.entries
.where(
(e) => childrenIds.contains(e.key),
)
.map((e) => e.value.inviteState)
.whereType<List<StrippedStateEvent>>();
final leftRooms = leaveUpdate?.entries
.where(
(e) => childrenIds.contains(e.key),
)
.map((e) => e.value.timeline?.events)
.whereType<List<MatrixEvent>>();
final bool hasJoinedRoom = joinedRooms?.any(
(events) => events.any(
(e) =>
e.senderId == widget.client.userID &&
e.type == EventTypes.RoomMember,
),
) ??
false;
final bool hasLeftRoom = leftRooms?.any(
(events) => events.any(
(e) =>
e.senderId == widget.client.userID &&
e.type == EventTypes.RoomMember,
),
) ??
false;
if (hasJoinedRoom || hasLeftRoom || (invitedRooms?.isNotEmpty ?? false)) {
return true;
}
final joinTimeline = joinUpdate?[widget.roomId]?.timeline?.events;
final leaveTimeline = leaveUpdate?[widget.roomId]?.timeline?.events;
if (joinTimeline == null && leaveTimeline == null) return false;
final bool hasJoinUpdate = joinTimeline!.any(
(event) => event.type == EventTypes.SpaceChild,
);
final bool hasLeaveUpdate = leaveTimeline!.any(
(event) => event.type == EventTypes.SpaceChild,
);
return hasJoinUpdate || hasLeaveUpdate;
}
int _sortSpaceChildren(
SpaceRoomsChunk a,
SpaceRoomsChunk b,
) {
final bool aIsSpace = a.roomType == 'm.space';
final bool bIsSpace = b.roomType == 'm.space';
if (aIsSpace && !bIsSpace) {
return -1;
} else if (!aIsSpace && bIsSpace) {
return 1;
}
return 0;
}
@override
Widget build(BuildContext context) => CourseChatsView(this);
}