feat: room search, room options sheet, .htaccess for E2EE

- Rooms screen: inline search filter with toggle — replaces the no-op button
- Chat screen: room options sheet — member list, invite, leave room
- rooms_repository: remove debug prints
- web/.htaccess: COOP+COEP headers for SharedArrayBuffer / LiveKit E2EE worker
  (was on production but missing from source; adds it to build output automatically)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 05:49:10 +10:00
parent 7e16b825c5
commit c1b5b31d48
5 changed files with 276 additions and 29 deletions

View File

@@ -1,18 +1,19 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (reply, react, copy, delete).
// + long-press context menu (reply, react, copy, delete) + room options sheet.
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' show MatrixFile;
import 'package:matrix/matrix.dart' show MatrixFile, Room;
import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart';
import '../../../shared/utils/mxc_url.dart';
import '../../../shared/widgets/matrix_avatar.dart';
import '../../rooms/presentation/user_search_dialog.dart';
import '../domain/message_model.dart';
import 'chat_controller.dart';
import 'message_bubble.dart';
@@ -34,6 +35,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
void _showRoomOptions(BuildContext context, Room room) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _RoomOptionsSheet(room: room),
);
}
void _setReply(MessageModel message) {
setState(() {
_replyToEventId = message.eventId;
@@ -87,7 +96,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Room options',
onPressed: () {},
onPressed: room != null
? () => _showRoomOptions(context, room)
: null,
),
],
),
@@ -239,6 +250,186 @@ class _MessageWithGestures extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Room options sheet
// ---------------------------------------------------------------------------
class _RoomOptionsSheet extends ConsumerStatefulWidget {
const _RoomOptionsSheet({required this.room});
final Room room;
@override
ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState();
}
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
bool _leaving = false;
Future<void> _leaveRoom(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Leave room?'),
content: Text(
'You will leave "${widget.room.getLocalizedDisplayname()}". '
'You can rejoin if you have an invite.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Leave'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _leaving = true);
try {
await widget.room.leave();
if (mounted) {
Navigator.of(context).pop(); // close sheet
GoRouter.of(context).go('/rooms');
}
} on Exception catch (e) {
if (mounted) {
setState(() => _leaving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not leave room: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider);
final theme = Theme.of(context);
final room = widget.room;
final members = room.getParticipants();
final canInvite = room.canInvite;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 16, 4),
child: Row(
children: [
Expanded(
child: Text(
room.getLocalizedDisplayname(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Members section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Text(
'Members (${members.length})',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
final name =
member.displayName ?? member.id.matrixLocalpart;
final avatarUrl =
resolveMxcUrl(client, member.avatarUrl);
final isMe = member.id == client.userID;
return ListTile(
dense: true,
leading: MatrixAvatar(
name: name,
avatarUrl: avatarUrl,
radius: 16,
),
title: Text(
isMe ? '$name (you)' : name,
style: theme.textTheme.bodyMedium,
),
subtitle: Text(
member.id,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
);
},
),
),
const Divider(),
// Actions
if (canInvite)
ListTile(
leading: const Icon(Icons.person_add_outlined),
title: const Text('Invite someone'),
onTap: () {
Navigator.of(context).pop();
showUserSearchDialog(context);
},
),
ListTile(
leading: _leaving
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
Icons.exit_to_app,
color: theme.colorScheme.error,
),
title: Text(
'Leave room',
style: TextStyle(color: theme.colorScheme.error),
),
enabled: !_leaving,
onTap: () => _leaveRoom(context),
),
],
),
),
);
}
}
class _MessageContextMenu extends StatelessWidget {
const _MessageContextMenu({
required this.message,