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 // 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:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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 '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/matrix_id.dart';
import '../../../shared/utils/mxc_url.dart'; import '../../../shared/utils/mxc_url.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../../rooms/presentation/user_search_dialog.dart';
import '../domain/message_model.dart'; import '../domain/message_model.dart';
import 'chat_controller.dart'; import 'chat_controller.dart';
import 'message_bubble.dart'; import 'message_bubble.dart';
@@ -34,6 +35,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String get _decodedRoomId => Uri.decodeComponent(widget.roomId); 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) { void _setReply(MessageModel message) {
setState(() { setState(() {
_replyToEventId = message.eventId; _replyToEventId = message.eventId;
@@ -87,7 +96,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
tooltip: 'Room options', 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 { class _MessageContextMenu extends StatelessWidget {
const _MessageContextMenu({ const _MessageContextMenu({
required this.message, required this.message,

View File

@@ -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. // Rooms repository. Reads room list from the Matrix SDK client.
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.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. /// Returns the current room list, sorted unread-first then by last activity.
List<RoomModel> getRooms() { List<RoomModel> getRooms() {
final rooms = _client.rooms; 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(); final models = rooms.map(_toModel).toList();
models.sort((a, b) { models.sort((a, b) {

View File

@@ -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. // Main rooms list screen with bottom navigation.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -22,6 +22,25 @@ class RoomsScreen extends ConsumerStatefulWidget {
class _RoomsScreenState extends ConsumerState<RoomsScreen> { class _RoomsScreenState extends ConsumerState<RoomsScreen> {
int _selectedIndex = 0; 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 = [ static const _destinations = [
NavigationDestination( NavigationDestination(
@@ -53,7 +72,7 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
Widget _buildBody() { Widget _buildBody() {
return switch (_selectedIndex) { return switch (_selectedIndex) {
0 => const _RoomListBody(), 0 => _RoomListBody(searchQuery: _searchQuery),
1 => const SpacesScreen(embedded: true), 1 => const SpacesScreen(embedded: true),
2 => const ProfileScreen(embedded: true), 2 => const ProfileScreen(embedded: true),
3 => const ConferenceTab(), 3 => const ConferenceTab(),
@@ -67,15 +86,24 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
return Scaffold( return Scaffold(
appBar: _selectedIndex == 0 appBar: _selectedIndex == 0
? AppBar( ? 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: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.search), icon: Icon(_searchActive ? Icons.close : Icons.search),
tooltip: 'Search rooms', tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
onPressed: () { onPressed: _toggleSearch,
// Phase 2: room search
},
), ),
if (!_searchActive)
IconButton( IconButton(
icon: const Icon(Icons.edit_square), icon: const Icon(Icons.edit_square),
tooltip: 'New message', tooltip: 'New message',
@@ -96,7 +124,9 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
} }
class _RoomListBody extends ConsumerWidget { class _RoomListBody extends ConsumerWidget {
const _RoomListBody(); const _RoomListBody({this.searchQuery = ''});
final String searchQuery;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -132,14 +162,39 @@ class _RoomListBody extends ConsumerWidget {
), ),
), ),
data: (rooms) { data: (rooms) {
if (rooms.isEmpty) { final filtered = searchQuery.trim().isEmpty
return const _EmptyRoomsState(); ? 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( return ListView.separated(
itemCount: rooms.length, itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72), separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final room = rooms[index]; final room = filtered[index];
return RoomTile( return RoomTile(
room: room, room: room,
onTap: () => onTap: () =>

View File

@@ -1,7 +1,7 @@
name: m8chat_app name: m8chat_app
description: "M8Chat — Matrix chat client for Android, iOS, and Web." description: "M8Chat — Matrix chat client for Android, iOS, and Web."
publish_to: 'none' publish_to: 'none'
version: 1.3.0+4 version: 1.3.1+5
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.0'

6
web/.htaccess Normal file
View File

@@ -0,0 +1,6 @@
# Required for SharedArrayBuffer — needed by LiveKit E2EE frame encryption.
# Without COOP+COEP, the LiveKit E2EE web worker cannot be created.
<IfModule mod_headers.c>
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>