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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user