From c1b5b31d48bb6c07e8928d7145afd64eee818992 Mon Sep 17 00:00:00 2001 From: help4bis Date: Mon, 27 Apr 2026 05:49:10 +1000 Subject: [PATCH] feat: room search, room options sheet, .htaccess for E2EE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../chat/presentation/chat_screen.dart | 199 +++++++++++++++++- lib/features/rooms/data/rooms_repository.dart | 7 +- .../rooms/presentation/rooms_screen.dart | 91 ++++++-- pubspec.yaml | 2 +- web/.htaccess | 6 + 5 files changed, 276 insertions(+), 29 deletions(-) create mode 100644 web/.htaccess diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 3a6060b..dbac829 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -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 { String get _decodedRoomId => Uri.decodeComponent(widget.roomId); + void _showRoomOptions(BuildContext context, Room room) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _RoomOptionsSheet(room: room), + ); + } + void _setReply(MessageModel message) { setState(() { _replyToEventId = message.eventId; @@ -87,7 +96,9 @@ class _ChatScreenState extends ConsumerState { 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 _leaveRoom(BuildContext context) async { + final confirmed = await showDialog( + 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, diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/rooms_repository.dart index bcd481e..421b555 100644 --- a/lib/features/rooms/data/rooms_repository.dart +++ b/lib/features/rooms/data/rooms_repository.dart @@ -1,8 +1,7 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27 // Rooms repository. Reads room list from the Matrix SDK client. import 'package:matrix/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; @@ -25,10 +24,6 @@ class RoomsRepository { /// Returns the current room list, sorted unread-first then by last activity. List getRooms() { final rooms = _client.rooms; - debugPrint('[Rooms] client.rooms count: ${rooms.length}'); - for (final r in rooms) { - debugPrint('[Rooms] ${r.id} "${r.getLocalizedDisplayname()}" isSpace=${r.isSpace}'); - } final models = rooms.map(_toModel).toList(); models.sort((a, b) { diff --git a/lib/features/rooms/presentation/rooms_screen.dart b/lib/features/rooms/presentation/rooms_screen.dart index a55eef2..9ed59fe 100644 --- a/lib/features/rooms/presentation/rooms_screen.dart +++ b/lib/features/rooms/presentation/rooms_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-11 +// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-27 // Main rooms list screen with bottom navigation. import 'package:flutter/material.dart'; @@ -22,6 +22,25 @@ class RoomsScreen extends ConsumerStatefulWidget { class _RoomsScreenState extends ConsumerState { int _selectedIndex = 0; + bool _searchActive = false; + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _toggleSearch() { + setState(() { + _searchActive = !_searchActive; + if (!_searchActive) { + _searchQuery = ''; + _searchController.clear(); + } + }); + } static const _destinations = [ NavigationDestination( @@ -53,7 +72,7 @@ class _RoomsScreenState extends ConsumerState { Widget _buildBody() { return switch (_selectedIndex) { - 0 => const _RoomListBody(), + 0 => _RoomListBody(searchQuery: _searchQuery), 1 => const SpacesScreen(embedded: true), 2 => const ProfileScreen(embedded: true), 3 => const ConferenceTab(), @@ -67,20 +86,29 @@ class _RoomsScreenState extends ConsumerState { return Scaffold( appBar: _selectedIndex == 0 ? AppBar( - title: const Text('M8Chat'), + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search rooms...', + border: InputBorder.none, + ), + onChanged: (v) => setState(() => _searchQuery = v), + ) + : const Text('M8Chat'), actions: [ IconButton( - icon: const Icon(Icons.search), - tooltip: 'Search rooms', - onPressed: () { - // Phase 2: room search - }, - ), - IconButton( - icon: const Icon(Icons.edit_square), - tooltip: 'New message', - onPressed: () => showUserSearchDialog(context), + icon: Icon(_searchActive ? Icons.close : Icons.search), + tooltip: _searchActive ? 'Cancel search' : 'Search rooms', + onPressed: _toggleSearch, ), + if (!_searchActive) + IconButton( + icon: const Icon(Icons.edit_square), + tooltip: 'New message', + onPressed: () => showUserSearchDialog(context), + ), ], ) : null, @@ -96,7 +124,9 @@ class _RoomsScreenState extends ConsumerState { } class _RoomListBody extends ConsumerWidget { - const _RoomListBody(); + const _RoomListBody({this.searchQuery = ''}); + + final String searchQuery; @override Widget build(BuildContext context, WidgetRef ref) { @@ -132,14 +162,39 @@ class _RoomListBody extends ConsumerWidget { ), ), data: (rooms) { - if (rooms.isEmpty) { - return const _EmptyRoomsState(); + final filtered = searchQuery.trim().isEmpty + ? rooms + : rooms + .where((r) => r.displayName + .toLowerCase() + .contains(searchQuery.trim().toLowerCase())) + .toList(); + + if (filtered.isEmpty) { + return searchQuery.isNotEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'No rooms match "$searchQuery".', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(153), + ), + ), + ), + ) + : const _EmptyRoomsState(); } + return ListView.separated( - itemCount: rooms.length, + itemCount: filtered.length, separatorBuilder: (_, __) => const Divider(height: 1, indent: 72), itemBuilder: (context, index) { - final room = rooms[index]; + final room = filtered[index]; return RoomTile( room: room, onTap: () => diff --git a/pubspec.yaml b/pubspec.yaml index 2bd0c81..525086b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.3.0+4 +version: 1.3.1+5 environment: sdk: '>=3.11.0 <4.0.0' diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 0000000..9021492 --- /dev/null +++ b/web/.htaccess @@ -0,0 +1,6 @@ +# Required for SharedArrayBuffer — needed by LiveKit E2EE frame encryption. +# Without COOP+COEP, the LiveKit E2EE web worker cannot be created. + + Header set Cross-Origin-Opener-Policy "same-origin" + Header set Cross-Origin-Embedder-Policy "require-corp" +