diff --git a/help4bis-claude-notes/decisions.md b/help4bis-claude-notes/decisions.md index f8b0864..3b6ab5a 100644 --- a/help4bis-claude-notes/decisions.md +++ b/help4bis-claude-notes/decisions.md @@ -82,3 +82,12 @@ - **Verified:** headless Chromium on production build: iframe renders, prejoin page shows, devices pass, name entry works, reaches the Prosody JWT auth gate (expected for a tokenless room). COEP require-corp turned out NOT to block conf.m8chat.au in current Chromium. - **Files affected:** lib/features/jitsi/data/jitsi_web_service.dart (1.2.0), lib/features/jitsi/presentation/jitsi_screen.dart (1.3.0), app_config 1.6.2, pubspec 1.6.2+10. - **Status:** Done — deployed to app2.m8chat.au (backups pre160/pre161 tarballs on server). + +### 2026-07-04 07:35 — Feature build round 1 (v1.7.0+11) +- **P1 Encrypted history:** new key_restore_prompt.dart offers recovery-key restore right after login when the account has secret storage and the fresh device can't read history (was buried in Profile). Fires once per session from rooms_screen initState. +- **P2 Incoming calls:** overlay existed but (a) was never mounted and (b) only listened for legacy m.call.invite. Fixed: IncomingCallOverlayHost mounted in app.dart MaterialApp.builder; matrixrtc_repository now also detects MSC3401 org.matrix.msc3401.call.member (what Element X sends), with stale-event + dedupe guards. Accept uses routerProvider (overlay is above the router navigator). +- **P3 Reactions/edit/delete:** reactions + delete were already wired (notes were stale). Added editMessage (sendTextEvent editEventId) + EditMessage notifier + Edit tile/dialog. Verified full context menu renders Reply/React/Copy/Edit/Delete. +- **P4 File/image send:** already implemented (sendFile + file_picker + UploadFile). Confirmed, no change needed. +- **P5 Panic button:** NEW feature/panic module. Config in account data au.m8chat.panic (room + message). Hold-to-fire button in rooms AppBar (only shows when armed); Profile tile to arm/disarm. Alert text sent FIRST, then best-effort m.location pin (geo: uri, msc3488) with 10s geolocation budget so a denied permission never blocks the alert. VERIFIED end-to-end: text + location with exact GPS landed in the target room via mock geolocation. +- **Status:** built, deployed app2.m8chat.au v1.7.0+11 (backup pre170 tarball on server). Analyze clean, tests pass. +- **Deferred:** ghost-device cleanup, rooms-provider rebuild perf, native session persistence — still open from original notes. diff --git a/lib/app/app.dart b/lib/app/app.dart index 3b23818..0d0fe30 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,9 +1,12 @@ -// Version: 1.0.0 | Created: 2026-04-01 -// Root MaterialApp widget. Wires together theme + router. +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-07-04 +// Root MaterialApp widget. Wires together theme + router + incoming-call +// overlay (v1.1.0 — the overlay existed but was never mounted, so incoming +// calls showed nothing). import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../features/calls/presentation/incoming_call_overlay.dart'; import 'router.dart'; import 'theme.dart'; @@ -26,6 +29,8 @@ class M8ChatApp extends ConsumerWidget { themeMode: ThemeMode.system, routerConfig: router, debugShowCheckedModeBanner: false, + builder: (context, child) => + IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()), ); } } diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 304cd81..b0a675f 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,7 +11,7 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.6.2'; + static const String appVersion = '1.7.0'; // Jitsi conferencing static const String jitsiDomain = 'conf.m8chat.au'; diff --git a/lib/features/calls/data/matrixrtc_repository.dart b/lib/features/calls/data/matrixrtc_repository.dart index e485a08..090ab01 100644 --- a/lib/features/calls/data/matrixrtc_repository.dart +++ b/lib/features/calls/data/matrixrtc_repository.dart @@ -1,6 +1,9 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04 // MatrixRTC repository — handles outgoing call invites and detects incoming -// calls via m.call.invite events per MSC4143 (MatrixRTC spec). +// calls. v1.3.0 adds MSC3401 call.member state-event detection: Element X +// (and app2 itself) signals group calls with org.matrix.msc3401.call.member, +// not legacy m.call.invite, so incoming calls from Element X were invisible. +// Both paths now guard against stale events replayed during initial sync. // // Incoming calls are surfaced via the incomingCallStream so the // IncomingCallOverlay can react to them. @@ -79,13 +82,39 @@ class MatrixRtcRepository { }, type: EventTypes.CallInvite); } - void _onEvent(EventUpdate update) { - if (update.type != EventUpdateType.timeline) return; - if (update.content['type'] != EventTypes.CallInvite) return; + /// MSC3401 MatrixRTC call membership state event (what Element X sends). + static const _kCallMemberEventType = 'org.matrix.msc3401.call.member'; + /// Events older than this are sync replays, not live calls. + static const _maxEventAge = Duration(seconds: 90); + + /// Membership keys we have already alerted on (roomId|sender|membership). + final Set _seenMembershipKeys = {}; + + void _onEvent(EventUpdate update) { + final eventType = update.content['type'] as String?; + if (eventType == EventTypes.CallInvite && + update.type == EventUpdateType.timeline) { + _onCallInvite(update); + } else if (eventType == _kCallMemberEventType && + (update.type == EventUpdateType.timeline || + update.type == EventUpdateType.state)) { + _onCallMember(update); + } + } + + bool _isStale(EventUpdate update) { + final ts = update.content['origin_server_ts'] as int?; + if (ts == null) return true; + final age = DateTime.now().millisecondsSinceEpoch - ts; + return age > _maxEventAge.inMilliseconds; + } + + /// Legacy 1:1 call invites (app2 → app2). + void _onCallInvite(EventUpdate update) { final senderId = update.content['sender'] as String?; - // Ignore our own invites. - if (senderId == _myUserId) return; + // Ignore our own invites and stale events replayed on initial sync. + if (senderId == _myUserId || _isStale(update)) return; final roomId = update.roomID; final content = update.content['content'] as Map?; @@ -93,21 +122,66 @@ class MatrixRtcRepository { final callId = content['call_id'] as String?; if (callId == null) return; + if (!_seenMembershipKeys.add('$roomId|$senderId|$callId')) return; - final room = _client.getRoomById(roomId); - final senderProfile = room?.unsafeGetUserFromMemoryOrFallback( - senderId ?? '', + _emitIncomingCall( + roomId: roomId, + senderId: senderId ?? '', + callId: callId, + isVideo: content['offer'] != null, ); + } + + /// MSC3401 group-call membership: someone (e.g. Element X) joined a call. + void _onCallMember(EventUpdate update) { + final senderId = update.content['sender'] as String?; + if (senderId == null || senderId == _myUserId || _isStale(update)) return; + + final content = update.content['content'] as Map?; + final memberships = content?['memberships'] as List?; + // Empty/cleared memberships means the caller hung up — nothing to show. + if (memberships == null || memberships.isEmpty) return; + + final membership = memberships.first as Map; + if (membership['application'] != 'm.call') return; + + final expiresTs = membership['expires_ts'] as int?; + if (expiresTs != null && + expiresTs < DateTime.now().millisecondsSinceEpoch) { + return; + } + + final membershipId = + (membership['membershipID'] ?? membership['call_id'] ?? '').toString(); + final roomId = update.roomID; + if (!_seenMembershipKeys.add('$roomId|$senderId|$membershipId')) return; + + _emitIncomingCall( + roomId: roomId, + senderId: senderId, + callId: membershipId.isEmpty ? 'matrixrtc' : membershipId, + isVideo: true, + ); + } + + void _emitIncomingCall({ + required String roomId, + required String senderId, + required String callId, + required bool isVideo, + }) { + final room = _client.getRoomById(roomId); + final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(senderId); _incomingCallController.add( IncomingCall( callId: callId, roomId: roomId, - callerId: senderId ?? '', + callerId: senderId, callerDisplayName: - senderProfile?.displayName ?? senderId?.matrixLocalpart ?? '', + senderProfile?.displayName ?? senderId.matrixLocalpart, callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl), - isVideo: (content['offer'] != null), + isVideo: isVideo, ), ); } diff --git a/lib/features/calls/presentation/incoming_call_overlay.dart b/lib/features/calls/presentation/incoming_call_overlay.dart index 0281279..248ee4e 100644 --- a/lib/features/calls/presentation/incoming_call_overlay.dart +++ b/lib/features/calls/presentation/incoming_call_overlay.dart @@ -1,10 +1,10 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04 // IncomingCallOverlay — full-screen overlay shown when an m.call.invite // arrives. Displays caller name/avatar, and Accept / Decline buttons. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; +import '../../../app/router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../shared/widgets/matrix_avatar.dart'; @@ -58,20 +58,27 @@ class IncomingCallOverlayHost extends ConsumerWidget { } } -class _IncomingCallOverlay extends StatefulWidget { +class _IncomingCallOverlay extends ConsumerStatefulWidget { const _IncomingCallOverlay({required this.call}); final IncomingCall call; @override - State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState(); + ConsumerState<_IncomingCallOverlay> createState() => + _IncomingCallOverlayState(); } -class _IncomingCallOverlayState extends State<_IncomingCallOverlay> { +class _IncomingCallOverlayState extends ConsumerState<_IncomingCallOverlay> { bool _dismissed = false; + String? _dismissedForCallId; @override Widget build(BuildContext context) { + // A new call resets the dismissal so the overlay can show again. + if (_dismissedForCallId != widget.call.callId) { + _dismissed = false; + _dismissedForCallId = widget.call.callId; + } if (_dismissed) return const SizedBox.shrink(); final theme = Theme.of(context); @@ -129,9 +136,13 @@ class _IncomingCallOverlayState extends State<_IncomingCallOverlay> { label: 'Accept', colour: Colors.green, onTap: () { - context.push( - '/calls/${Uri.encodeComponent(call.roomId)}', - ); + // The overlay lives above the router's navigator, so + // context.push would not find GoRouter — use the + // router instance directly, then dismiss the overlay. + setState(() => _dismissed = true); + ref + .read(routerProvider) + .push('/calls/${Uri.encodeComponent(call.roomId)}'); }, ), ], diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index 577b3c6..26d8bdd 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-10 +// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Chat repository — bridges Matrix SDK timeline to app domain models. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. @@ -73,6 +73,17 @@ class ChatRepository { await room.sendFileEvent(file); } + /// Replaces the body of an existing text message (m.replace edit). + Future editMessage( + String roomId, + String eventId, + String newText, + ) async { + final room = _getRoom(roomId); + if (room == null) return; + await room.sendTextEvent(newText, editEventId: eventId); + } + /// Sends an emoji reaction to [eventId]. Future sendReaction(String roomId, String eventId, String emoji) async { final room = _getRoom(roomId); diff --git a/lib/features/chat/presentation/chat_controller.dart b/lib/features/chat/presentation/chat_controller.dart index 6babb52..177af0d 100644 --- a/lib/features/chat/presentation/chat_controller.dart +++ b/lib/features/chat/presentation/chat_controller.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Riverpod providers for chat timeline, send, upload, react, reply. import 'package:matrix/matrix.dart' show MatrixFile; @@ -84,6 +84,27 @@ class SendReaction extends _$SendReaction { } } +/// Edits an existing text message. +@riverpod +class EditMessage extends _$EditMessage { + @override + bool build() => false; + + Future edit(String roomId, String eventId, String newText) async { + state = true; + try { + await ref + .read(chatRepositoryProvider) + .editMessage(roomId, eventId, newText); + return null; + } on Exception catch (e) { + return e.toString(); + } finally { + state = false; + } + } +} + /// Deletes (redacts) a message by eventId. @riverpod class DeleteMessage extends _$DeleteMessage { diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index f34e502..0080aba 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.3.1 | Created: 2026-04-01 | Updated: 2026-07-03 +// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Full chat screen — timeline + input + typing indicators + read receipts // + long-press context menu (reply, react, copy, delete) + room options sheet. @@ -217,6 +217,12 @@ class _MessageWithGestures extends ConsumerWidget { context, ).showSnackBar(const SnackBar(content: Text('Message copied.'))); }, + onEdit: message.isMine && message.body != null + ? () { + Navigator.pop(context); + _showEditDialog(context, ref); + } + : null, onDelete: message.isMine ? () async { Navigator.pop(context); @@ -229,6 +235,44 @@ class _MessageWithGestures extends ConsumerWidget { ); } + void _showEditDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(text: message.body ?? ''); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Edit message'), + content: TextField( + controller: controller, + autofocus: true, + maxLines: null, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final newText = controller.text.trim(); + Navigator.of(ctx).pop(); + if (newText.isEmpty || newText == message.body) return; + final error = await ref + .read(editMessageProvider.notifier) + .edit(roomId, message.eventId, newText); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Edit failed: $error')), + ); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showEmojiPicker(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, @@ -437,6 +481,7 @@ class _MessageContextMenu extends StatelessWidget { required this.onReply, required this.onReact, required this.onCopy, + this.onEdit, this.onDelete, }); @@ -445,6 +490,7 @@ class _MessageContextMenu extends StatelessWidget { final VoidCallback onReply; final VoidCallback onReact; final VoidCallback onCopy; + final VoidCallback? onEdit; final VoidCallback? onDelete; @override @@ -469,6 +515,12 @@ class _MessageContextMenu extends StatelessWidget { title: const Text('Copy text'), onTap: onCopy, ), + if (onEdit != null) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Edit'), + onTap: onEdit, + ), if (onDelete != null) ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), diff --git a/lib/features/panic/data/panic_repository.dart b/lib/features/panic/data/panic_repository.dart new file mode 100644 index 0000000..8036386 --- /dev/null +++ b/lib/features/panic/data/panic_repository.dart @@ -0,0 +1,142 @@ +// Version: 1.0.0 | Created: 2026-07-04 +// Panic button repository — config in Matrix account data plus the trigger +// action: send an alert text message and (when the browser grants it) an +// m.location pin to the configured room. +// +// The alert message is sent FIRST so a denied/slow geolocation permission can +// never block the emergency alert itself. +// +// NOTE web-only geolocation (dart:js_interop + package:web). When native +// Android/iOS builds happen, gate this behind a conditional import. + +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:web/web.dart' as web; + +import '../../../core/network/matrix_client.dart'; + +part 'panic_repository.g.dart'; + +/// Account-data event type holding the panic configuration. +const kPanicConfigType = 'au.m8chat.panic'; + +/// Panic button configuration. +class PanicConfig { + const PanicConfig({required this.roomId, required this.message}); + + final String roomId; + final String message; + + static PanicConfig? fromAccountData(Client client) { + final content = client.accountData[kPanicConfigType]?.content; + final roomId = content?['room_id']; + if (roomId is! String || roomId.isEmpty) return null; + final message = content?['message']; + return PanicConfig( + roomId: roomId, + message: message is String && message.isNotEmpty + ? message + : 'EMERGENCY: I need help. This is an automated alert from M8Chat.', + ); + } +} + +class PanicRepository { + PanicRepository({required Client client}) : _client = client; + + final Client _client; + + PanicConfig? get config => PanicConfig.fromAccountData(_client); + + Future saveConfig({ + required String roomId, + required String message, + }) async { + final userId = _client.userID; + if (userId == null) throw Exception('Not logged in'); + await _client.setAccountData(userId, kPanicConfigType, { + 'room_id': roomId, + 'message': message, + }); + } + + Future clearConfig() async { + final userId = _client.userID; + if (userId == null) return; + await _client.setAccountData(userId, kPanicConfigType, {}); + } + + /// Fires the panic alert. Returns an error string, or null on success. + Future trigger() async { + final cfg = config; + if (cfg == null) return 'Panic button is not configured.'; + final room = _client.getRoomById(cfg.roomId); + if (room == null) return 'Configured room is no longer available.'; + + // 1. The alert text goes out first — never blocked by geolocation. + try { + await room.sendTextEvent('\u{1F6A8} ${cfg.message}'); + } on Exception catch (e) { + return 'Could not send alert: $e'; + } + + // 2. Best-effort location pin. + final position = await _currentPosition(); + if (position != null) { + final (lat, lon) = position; + final geoUri = 'geo:$lat,$lon'; + try { + await room.sendEvent({ + 'msgtype': 'm.location', + 'body': 'Emergency location: $geoUri', + 'geo_uri': geoUri, + 'org.matrix.msc3488.location': {'uri': geoUri}, + 'org.matrix.msc3488.asset': {'type': 'm.pin'}, + }, type: 'm.room.message'); + } on Exception catch (e) { + debugPrint('[Panic] location send failed: $e'); + } + } else { + debugPrint('[Panic] no geolocation available — alert sent without it'); + } + return null; + } + + /// Browser geolocation with a hard 10s budget. Null when denied/unavailable. + Future<(double, double)?> _currentPosition() async { + final completer = Completer<(double, double)?>(); + try { + web.window.navigator.geolocation.getCurrentPosition( + (web.GeolocationPosition pos) { + if (!completer.isCompleted) { + completer.complete((pos.coords.latitude, pos.coords.longitude)); + } + }.toJS, + (web.GeolocationPositionError err) { + if (!completer.isCompleted) completer.complete(null); + }.toJS, + web.PositionOptions( + enableHighAccuracy: true, + timeout: 8000, + maximumAge: 60000, + ), + ); + } catch (e) { + debugPrint('[Panic] geolocation API unavailable: $e'); + return null; + } + return completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () => null, + ); + } +} + +@Riverpod(keepAlive: true) +PanicRepository panicRepository(Ref ref) { + return PanicRepository(client: ref.watch(matrixClientProvider)); +} diff --git a/lib/features/panic/presentation/panic_button.dart b/lib/features/panic/presentation/panic_button.dart new file mode 100644 index 0000000..933ca17 --- /dev/null +++ b/lib/features/panic/presentation/panic_button.dart @@ -0,0 +1,70 @@ +// Version: 1.0.0 | Created: 2026-07-04 +// Panic trigger button for the rooms AppBar. Push-to-talk style: +// HOLD to fire the alert; a plain tap only shows the hint. Only rendered +// when a panic configuration exists in account data. + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/panic_repository.dart'; + +class PanicButton extends ConsumerStatefulWidget { + const PanicButton({super.key}); + + @override + ConsumerState createState() => _PanicButtonState(); +} + +class _PanicButtonState extends ConsumerState { + bool _sending = false; + + Future _trigger() async { + if (_sending) return; + setState(() => _sending = true); + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Sending emergency alert…')), + ); + final error = await ref.read(panicRepositoryProvider).trigger(); + if (!mounted) return; + setState(() => _sending = false); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + backgroundColor: error == null ? Colors.green.shade800 : null, + content: Text( + error ?? 'Emergency alert sent with your location.', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Hide when not configured. Config lives in account data, which is + // available once sync has run; rebuilds happen with the parent screen. + final configured = ref.watch(panicRepositoryProvider).config != null; + if (!configured) return const SizedBox.shrink(); + + return GestureDetector( + onLongPress: _trigger, + child: IconButton( + tooltip: 'Hold to send emergency alert', + icon: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.emergency_share, color: Colors.red), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Hold the button to send the emergency alert.'), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/panic/presentation/panic_settings_dialog.dart b/lib/features/panic/presentation/panic_settings_dialog.dart new file mode 100644 index 0000000..432914b --- /dev/null +++ b/lib/features/panic/presentation/panic_settings_dialog.dart @@ -0,0 +1,166 @@ +// Version: 1.0.0 | Created: 2026-07-04 +// Panic button settings — choose the alert room and message. +// Opened from Profile > Emergency panic button. + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../core/network/matrix_client.dart'; +import '../data/panic_repository.dart'; + +Future showPanicSettingsDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + builder: (_) => const _PanicSettingsDialog(), + ); +} + +class _PanicSettingsDialog extends ConsumerStatefulWidget { + const _PanicSettingsDialog(); + + @override + ConsumerState<_PanicSettingsDialog> createState() => + _PanicSettingsDialogState(); +} + +class _PanicSettingsDialogState extends ConsumerState<_PanicSettingsDialog> { + String? _roomId; + late final TextEditingController _messageController; + bool _saving = false; + + @override + void initState() { + super.initState(); + final cfg = ref.read(panicRepositoryProvider).config; + _roomId = cfg?.roomId; + _messageController = TextEditingController( + text: cfg?.message ?? + 'EMERGENCY: I need help. This is an automated alert from M8Chat.', + ); + } + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + Future _save() async { + final roomId = _roomId; + if (roomId == null) return; + setState(() => _saving = true); + try { + await ref.read(panicRepositoryProvider).saveConfig( + roomId: roomId, + message: _messageController.text.trim(), + ); + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Panic button armed. Hold the alert icon ' + 'on the rooms screen to trigger it.'), + ), + ); + } + } on Exception catch (e) { + if (mounted) { + setState(() => _saving = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Could not save: $e'))); + } + } + } + + Future _disable() async { + setState(() => _saving = true); + await ref.read(panicRepositoryProvider).clearConfig(); + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Panic button disabled.')), + ); + } + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(matrixClientProvider); + final rooms = client.rooms + .where((r) => r.membership == Membership.join) + .toList() + ..sort((a, b) => a + .getLocalizedDisplayname() + .toLowerCase() + .compareTo(b.getLocalizedDisplayname().toLowerCase())); + final hasConfig = ref.read(panicRepositoryProvider).config != null; + + return AlertDialog( + title: const Text('Emergency panic button'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'When you hold the alert icon, M8Chat sends this message ' + 'and your current location to the room you choose here.', + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: + rooms.any((r) => r.id == _roomId) ? _roomId : null, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Send alert to room', + border: OutlineInputBorder(), + ), + items: [ + for (final room in rooms) + DropdownMenuItem( + value: room.id, + child: Text( + room.getLocalizedDisplayname(), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (v) => setState(() => _roomId = v), + ), + const SizedBox(height: 16), + TextField( + controller: _messageController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Alert message', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + actions: [ + if (hasConfig) + TextButton( + onPressed: _saving ? null : _disable, + child: const Text('Disable', style: TextStyle(color: Colors.red)), + ), + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving || _roomId == null ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/key_restore_prompt.dart b/lib/features/profile/presentation/key_restore_prompt.dart new file mode 100644 index 0000000..f0d62d3 --- /dev/null +++ b/lib/features/profile/presentation/key_restore_prompt.dart @@ -0,0 +1,80 @@ +// Version: 1.0.0 | Created: 2026-07-04 +// Post-login prompt: if the account has secret storage (key backup) and this +// fresh device cannot read encrypted history yet, offer the recovery-key +// restore straight away instead of leaving it buried in Profile > Security. +// +// Shown at most once per login session. "Later" keeps the old behaviour +// (restore manually from the Profile tab). + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import 'key_restore_dialog.dart'; + +bool _promptedThisSession = false; + +/// Reset when a new login happens (called from the login flow if needed). +void resetKeyRestorePrompt() => _promptedThisSession = false; + +/// Checks whether the freshly logged-in session needs a key restore and, +/// if so, asks the user. Safe to call multiple times; only fires once. +Future maybePromptKeyRestore(BuildContext context, Client client) async { + if (_promptedThisSession) return; + _promptedThisSession = true; + + try { + // Wait for the first sync so account data (secret storage config) exists. + if (client.prevBatch == null) { + await client.onSync.stream.first.timeout(const Duration(seconds: 30)); + } + } on TimeoutException { + debugPrint('[KeyRestorePrompt] first sync timed out — skipping'); + return; + } + + final enc = client.encryption; + if (enc == null) return; + + // No secret storage on the account → nothing to restore from. + final hasSecretStorage = + client.accountData.containsKey('m.secret_storage.default_key'); + if (!hasSecretStorage) return; + + // Keys already cached (or session verified) → nothing to do. + var cached = false; + try { + cached = await enc.keyManager.isCached(); + } on Exception catch (e) { + debugPrint('[KeyRestorePrompt] isCached failed: $e'); + } + if (cached && !client.isUnknownSession) return; + + if (!context.mounted) return; + + final wantsRestore = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Restore encrypted messages?'), + content: const Text( + 'This device cannot read your older encrypted messages yet. ' + 'Enter your recovery key now to unlock them, or do it later ' + 'from Profile > Security & Privacy.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Later'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Enter recovery key'), + ), + ], + ), + ); + + if (wantsRestore != true || !context.mounted) return; + await showKeyRestoreDialog(context, client); +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index cb61ad2..eec3b3a 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.4.1 | Created: 2026-04-01 | Updated: 2026-07-03 +// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Profile screen. Shows current user info and logout button. import 'package:flutter/material.dart'; @@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart'; import '../../../core/network/matrix_client.dart'; import '../../../shared/utils/matrix_id.dart'; import '../../../shared/widgets/matrix_avatar.dart'; +import '../../panic/data/panic_repository.dart'; +import '../../panic/presentation/panic_settings_dialog.dart'; import 'key_restore_dialog.dart'; import 'security_setup_dialog.dart'; @@ -147,6 +149,16 @@ class ProfileScreen extends ConsumerWidget { ), ], ), + ListTile( + leading: const Icon(Icons.emergency_share, color: Colors.red), + title: const Text('Emergency panic button'), + subtitle: Text( + ref.watch(panicRepositoryProvider).config != null + ? 'Armed. Hold the alert icon on the rooms screen to trigger.' + : 'Send your location and an alert to a room of your choice', + ), + onTap: () => showPanicSettingsDialog(context, ref), + ), const SizedBox(height: 16), const Divider(), const SizedBox(height: 16), diff --git a/lib/features/rooms/presentation/rooms_screen.dart b/lib/features/rooms/presentation/rooms_screen.dart index f9fabdd..41406c1 100644 --- a/lib/features/rooms/presentation/rooms_screen.dart +++ b/lib/features/rooms/presentation/rooms_screen.dart @@ -1,12 +1,17 @@ -// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-04-27 +// Version: 1.6.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Main rooms list screen with bottom navigation. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'dart:async'; + +import '../../../core/network/matrix_client.dart'; import '../../help/presentation/help_tab.dart'; +import '../../panic/presentation/panic_button.dart'; import '../../jitsi/presentation/conference_tab.dart'; +import '../../profile/presentation/key_restore_prompt.dart'; import '../../profile/presentation/profile_screen.dart'; import '../../spaces/presentation/spaces_screen.dart'; import 'public_rooms_dialog.dart'; @@ -27,6 +32,17 @@ class _RoomsScreenState extends ConsumerState { String _searchQuery = ''; final _searchController = TextEditingController(); + @override + void initState() { + super.initState(); + // Offer encrypted-history restore right after login (fires once). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final client = ref.read(matrixClientProvider); + unawaited(maybePromptKeyRestore(context, client)); + }); + } + @override void dispose() { _searchController.dispose(); @@ -99,6 +115,7 @@ class _RoomsScreenState extends ConsumerState { ) : const Text('M8Chat'), actions: [ + const PanicButton(), IconButton( icon: Icon(_searchActive ? Icons.close : Icons.search), tooltip: _searchActive ? 'Cancel search' : 'Search rooms', diff --git a/pubspec.yaml b/pubspec.yaml index 487d8ef..df4295a 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.6.2+10 +version: 1.7.0+11 environment: sdk: '>=3.11.0 <4.0.0'