diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9eaee21fc..c801d94ce 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4936,5 +4936,7 @@ "permissions": "Permissions", "spaceChildPermission": "Who can add new chats and subspaces to this space", "addEnvironmentOverride": "Add environment override", - "defaultOption": "Default" + "defaultOption": "Default", + "deleteChatDesc": "Are you sure you want to delete this chat? It will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics.", + "deleteSpaceDesc": "The space and any selected chats and/or subspaces will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics. This action cannot be undone." } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index dcf16676b..df76ce4b0 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -19,6 +19,8 @@ import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pangea/chat_list/utils/app_version_util.dart'; import 'package:fluffychat/pangea/chat_list/utils/chat_list_handle_space_tap.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/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; @@ -885,7 +887,10 @@ class ChatListController extends State mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.delete_outlined, + // #Pangea + // Icons.delete_outlined, + Icons.logout_outlined, + // Pangea# color: Theme.of(context).colorScheme.onErrorContainer, ), const SizedBox(width: 12), @@ -900,6 +905,28 @@ class ChatListController extends State ], ), ), + // #Pangea + if (room.isRoomAdmin) + 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, + ), + ), + ], + ), + ), + // Pangea# ], ); @@ -1021,6 +1048,37 @@ class ChatListController extends State }, ); return; + case ChatContextAction.delete: + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => DeleteSpaceDialog(space: room), + ); + if (resp == true && mounted) { + context.go("/rooms?spaceId=clear"); + } + } 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 (resp.isError) return; + if (mounted) context.go("/rooms?spaceId=clear"); + } + return; // Pangea# case ChatContextAction.block: final userId = @@ -1295,5 +1353,6 @@ enum ChatContextAction { block, // #Pangea removeFromSpace, + delete, // Pangea# } diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 00240182f..a28f3abd7 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -8,10 +8,12 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/class_name_header.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/download_space_analytics_button.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/room_capacity_button.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/visibility_toggle.dart'; @@ -426,6 +428,58 @@ class PangeaChatDetailsView extends StatelessWidget { }, ), Divider(color: theme.dividerColor, height: 1), + if (room.isRoomAdmin) + ListTile( + title: Text( + L10n.of(context).delete, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + ), + onTap: () async { + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => + DeleteSpaceDialog(space: room), + ); + + if (resp == true) { + context.go("/rooms?spaceId=clear"); + } + } 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; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (resp.isError) return; + context.go("/rooms?spaceId=clear"); + } + }, + ), + Divider(color: theme.dividerColor, height: 1), ListTile( title: Text( L10n.of(context).countParticipants( diff --git a/lib/pangea/chat_settings/utils/delete_room.dart b/lib/pangea/chat_settings/utils/delete_room.dart new file mode 100644 index 000000000..4cf22806a --- /dev/null +++ b/lib/pangea/chat_settings/utils/delete_room.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/matrix_api_lite/generated/api.dart'; + +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; + +extension on Api { + // Send a POST request to /_synapse/client/pangea/v1/delete_room with JSON body {room_id: string}. + // Response 200 OK format: { message: "Deleted" }. + // Requester must be member of the room and have the highest power level of the room to perform this request. + Future delete(String roomId) async { + final requestUri = Uri( + path: '_synapse/client/pangea/v1/delete_room', + ); + final request = Request('POST', baseUri!.resolveUri(requestUri)); + request.headers['content-type'] = 'application/json'; + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + request.bodyBytes = utf8.encode( + jsonEncode({ + 'room_id': roomId, + }), + ); + final response = await httpClient.send(request); + if (response.statusCode != 200) { + throw Exception('http error response'); + } + } +} + +extension DeleteRoom on Room { + Future delete() async { + await client.delete(id); + } + + Future> getSpaceChildrenToDelete() async { + final List rooms = []; + String? nextBatch; + int calls = 0; + + while ((nextBatch != null || calls == 0) && calls < 10) { + final resp = await client.getSpaceHierarchy( + id, + from: nextBatch, + limit: 100, + ); + rooms.addAll(resp.rooms); + nextBatch = resp.nextBatch; + calls++; + } + + return rooms + .where( + (r) => r.roomType != PangeaRoomTypes.analytics && r.roomId != id, + ) + .toList(); + } +} diff --git a/lib/pangea/chat_settings/widgets/delete_space_dialog.dart b/lib/pangea/chat_settings/widgets/delete_space_dialog.dart new file mode 100644 index 000000000..577902c88 --- /dev/null +++ b/lib/pangea/chat_settings/widgets/delete_space_dialog.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; + +class DeleteSpaceDialog extends StatefulWidget { + final Room space; + const DeleteSpaceDialog({ + super.key, + required this.space, + }); + + @override + State createState() => DeleteSpaceDialogState(); +} + +class DeleteSpaceDialogState extends State { + List _rooms = []; + final List _roomsToDelete = []; + + bool _loadingRooms = true; + String? _roomLoadError; + + bool _deleting = false; + String? _deleteError; + + @override + void initState() { + super.initState(); + _getSpaceChildrenToDelete(); + } + + Future _getSpaceChildrenToDelete() async { + setState(() { + _loadingRooms = true; + _roomLoadError = null; + }); + + try { + _rooms = await widget.space.getSpaceChildrenToDelete(); + } catch (e, s) { + _roomLoadError = L10n.of(context).oopsSomethingWentWrong; + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": widget.space.id, + }, + ); + } finally { + setState(() { + _loadingRooms = false; + }); + } + } + + void _onRoomSelected( + bool? selected, + SpaceRoomsChunk room, + ) { + if (selected == null || + (selected && _roomsToDelete.contains(room)) || + (!selected && !_roomsToDelete.contains(room))) { + return; + } + + setState(() { + selected ? _roomsToDelete.add(room) : _roomsToDelete.remove(room); + }); + } + + Future _deleteSpace() async { + setState(() { + _deleting = true; + _deleteError = null; + }); + + try { + final List> deleteFutures = []; + for (final room in _roomsToDelete) { + final roomInstance = widget.space.client.getRoomById(room.roomId); + if (roomInstance != null) { + deleteFutures.add(roomInstance.delete()); + } + } + await Future.wait(deleteFutures); + await widget.space.delete(); + Navigator.of(context).pop(true); + } catch (e, s) { + _deleteError = L10n.of(context).oopsSomethingWentWrong; + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": widget.space.id, + }, + ); + } finally { + if (mounted) { + setState(() { + _deleting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + constraints: const BoxConstraints( + maxWidth: 400, + ), + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: BorderRadius.circular(32.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).areYouSure, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Text( + L10n.of(context).deleteSpaceDesc, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + SizedBox( + height: 300, + child: Builder( + builder: (context) { + if (_loadingRooms) { + return const Center( + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + if (_roomLoadError != null) { + return Center( + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + Text(L10n.of(context).oopsSomethingWentWrong), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: ListView.builder( + shrinkWrap: true, + itemCount: _rooms.length, + itemBuilder: (context, index) { + final chunk = _rooms[index]; + + final room = + widget.space.client.getRoomById(chunk.roomId); + final isMember = room != null && + room.membership == Membership.join && + room.isRoomAdmin; + + final displayname = chunk.name ?? + chunk.canonicalAlias ?? + L10n.of(context).emptyChat; + + return AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: isMember ? 1 : 0.5, + child: CheckboxListTile( + value: _roomsToDelete.contains(chunk), + onChanged: isMember + ? (value) => _onRoomSelected(value, chunk) + : null, + title: Text(displayname), + controlAffinity: ListTileControlAffinity.leading, + ), + ); + }, + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0), + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: OutlinedButton( + onPressed: _deleting ? null : _deleteSpace, + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide( + color: _deleting + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.error, + ), + ), + child: _deleting + ? const SizedBox( + height: 10, + width: 100, + child: LinearProgressIndicator(), + ) + : Text(L10n.of(context).delete), + ), + ), + OutlinedButton( + onPressed: Navigator.of(context).pop, + child: Text(L10n.of(context).cancel), + ), + ], + ), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _deleteError != null + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context).oopsSomethingWentWrong), + ) + : const SizedBox(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 3e31eb0f6..fc97ed381 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -48,7 +48,7 @@ class PracticeSelectionRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) => _parsePracticeSelection(key))