feat: allow admins to delete rooms

pull/2245/head
ggurdin 6 months ago
parent 8bac7b8c51
commit fc9c175117
No known key found for this signature in database
GPG Key ID: A01CB41737CBB478

@ -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."
}

@ -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<ChatList>
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<ChatList>
],
),
),
// #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<ChatList>
},
);
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?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#
}

@ -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<bool?>(
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(

@ -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<void> 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<void> delete() async {
await client.delete(id);
}
Future<List<SpaceRoomsChunk>> getSpaceChildrenToDelete() async {
final List<SpaceRoomsChunk> 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();
}
}

@ -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<DeleteSpaceDialog> createState() => DeleteSpaceDialogState();
}
class DeleteSpaceDialogState extends State<DeleteSpaceDialog> {
List<SpaceRoomsChunk> _rooms = [];
final List<SpaceRoomsChunk> _roomsToDelete = [];
bool _loadingRooms = true;
String? _roomLoadError;
bool _deleting = false;
String? _deleteError;
@override
void initState() {
super.initState();
_getSpaceChildrenToDelete();
}
Future<void> _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<void> _deleteSpace() async {
setState(() {
_deleting = true;
_deleteError = null;
});
try {
final List<Future<void>> 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(),
),
],
),
),
);
}
}

@ -48,7 +48,7 @@ class PracticeSelectionRepo {
}
static void clean() {
final Iterable<String> keys = _storage.getKeys();
final keys = _storage.getKeys();
if (keys.length > 300) {
final entries = keys
.map((key) => _parsePracticeSelection(key))

Loading…
Cancel
Save