feat: encrypted-history prompt, incoming-call alerts, message edit, panic button (v1.7.0+11)

P1 key_restore_prompt: offer recovery-key restore right after login
P2 incoming calls: mount overlay in app.dart + detect MSC3401 call.member
   (Element X signalling) — calls were previously invisible
P3 message edit: editMessage/EditMessage + Edit dialog (reactions/delete
   were already wired)
P5 panic button: new feature/panic module — hold-to-send alert text +
   m.location to a configured room; alert sent before geolocation so a
   denied permission never blocks it

All verified end-to-end in headless Chromium against production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 07:30:57 +10:00
parent 923c0ad878
commit a36df1c961
15 changed files with 700 additions and 30 deletions

View File

@@ -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<void> 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<void> sendReaction(String roomId, String eventId, String emoji) async {
final room = _getRoom(roomId);

View File

@@ -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<String?> 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 {

View File

@@ -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<void>(
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<void>(
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),