Merge pull request #2815 from pangeachat/2796-deleting-chats

feat: allow admins to delete rooms
This commit is contained in:
ggurdin 2025-05-22 13:54:05 -04:00 committed by GitHub
commit bbd3d29f55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 435 additions and 1 deletions

View file

@ -4937,6 +4937,8 @@
"spaceChildPermission": "Who can add new chats and subspaces to this space",
"addEnvironmentOverride": "Add environment override",
"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.",
"chatWithActivities": "Chat with activities",
"findYourPeople": "Find your people",
"launch": "Launch",

View file

@ -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#
}

View file

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

View file

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

View file

@ -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(),
),
],
),
),
);
}
}