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
|
// 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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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
6
web/.htaccess
Normal 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>
|
||||||
Reference in New Issue
Block a user