Files
Warp/apps/mobile/lib/widgets/message.dart
T
tajniak81andClaude Opus 5.5 3356089c13 Android app (apps/mobile) with Flutter
apps/mobile is a Matrix client for Android on the famedly Matrix Dart SDK,
with vodozemac (compiled from Rust during the build) for end-to-end
encryption. It speaks the plain Client-Server API and uses the web
client's design tokens, fonts and symbol.

- Sign in by server name (.well-known), homeserver address or full user ID;
  sign up with the dummy and registration token stages. Sessions survive
  restarts: SQLite in private storage, credentials also in the Keystore,
  both excluded from backups and device transfers.
- Room list with unread and mention counts, invites, search and filters.
  Timeline with grouping, day separators, notices, history on scroll,
  links, images and files (authenticated media). Sending text, photos,
  camera shots and files, with Sending/Sent/Read/Not sent and Retry.
  Typing, receipts, deleting. New rooms and DMs, encrypted by default;
  joining by address; room info with members, invite, rename, topic,
  notification level, leave.
- Encryption: one-step setup behind a recovery key, verifying a new session
  with the recovery key (restoring the backup) or another session, emoji
  verification of people and sessions, reset. Undecryptable messages say
  why. Session list with verify and sign out.
- Notifications while running, and push through UnifiedPush (no Firebase):
  the distributor's endpoint becomes an event_id_only pusher, the gateway
  is discovered, and a push starts the app headless to fetch, decrypt and
  show the event.

Make targets: mobile (release APKs per ABI into bin/), mobile-run,
mobile-test. Kotlin incremental compilation is off (Windows, project and
pub cache on different drives), and Tink's JVM artifact is excluded in
favour of tink-android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:45:06 +02:00

695 lines
21 KiB
Dart

import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/matrix.dart';
import 'package:url_launcher/url_launcher.dart';
import '../matrix/client.dart';
import '../matrix/media.dart';
import '../theme.dart';
import '../util/format.dart';
import 'avatar.dart';
const _i18n = MatrixDefaultLocalizations();
/// A one-line room event in the middle of the timeline: "Alice joined".
class NoticeRow extends StatelessWidget {
const NoticeRow({super.key, required this.event});
final Event event;
@override
Widget build(BuildContext context) {
final c = context.warp;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 6),
child: Text(
event.calcLocalizedBodyFallback(_i18n),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: c.inkMuted),
),
);
}
}
/// A message: a bubble on the right for the user's own, on the left with
/// the sender's avatar and name for everyone else's.
class MessageRow extends StatelessWidget {
const MessageRow({
super.key,
required this.event,
required this.timeline,
required this.firstInGroup,
required this.lastInGroup,
required this.readByOthers,
});
final Event event;
final Timeline timeline;
final bool firstInGroup;
final bool lastInGroup;
final bool readByOthers;
bool get _mine => event.senderId == event.room.client.userID;
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
final display = event.getDisplayEvent(timeline);
final sender = event.senderFromMemoryOrFallback;
final showSender = !_mine && firstInGroup && !event.room.isDirectChat;
final bubbleColour = _mine ? c.tealSoft : c.bubbleOther;
final isMedia =
!display.redacted &&
(display.messageType == MessageTypes.Image ||
display.messageType == MessageTypes.Sticker);
final radius = Radius.circular(18);
final tight = Radius.circular(6);
final shape = BorderRadius.only(
topLeft: !_mine && !firstInGroup ? tight : radius,
bottomLeft: !_mine && !lastInGroup ? tight : radius,
topRight: _mine && !firstInGroup ? tight : radius,
bottomRight: _mine && !lastInGroup ? tight : radius,
);
final body = _MessageBody(event: display, original: event, mine: _mine);
final bubble = GestureDetector(
onLongPress: () => _showActions(context, display),
child: isMedia
? ClipRRect(borderRadius: shape, child: body)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: bubbleColour,
borderRadius: shape,
),
child: body,
),
);
return Padding(
padding: EdgeInsets.fromLTRB(
_mine ? 56 : 12,
firstInGroup ? 8 : 2,
_mine ? 12 : 56,
lastInGroup ? 4 : 0,
),
child: Row(
mainAxisAlignment: _mine
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!_mine) ...[
SizedBox(
width: 32,
child: lastInGroup
? Avatar(
name: sender.calcDisplayname(),
seed: sender.id,
mxc: sender.avatarUrl,
size: 32,
)
: null,
),
const SizedBox(width: 8),
],
Flexible(
child: Column(
crossAxisAlignment: _mine
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (showSender)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 2),
child: Text(
sender.calcDisplayname(),
style: text.labelMedium?.copyWith(
color: c.tealText,
fontWeight: FontWeight.w600,
),
),
),
bubble,
if (lastInGroup || event.status.isError)
Padding(
padding: const EdgeInsets.only(top: 3, left: 4, right: 4),
child: _Status(
event: event,
edited:
display != event ||
event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
),
mine: _mine,
read: readByOthers,
),
),
],
),
),
],
),
);
}
Future<void> _showActions(BuildContext context, Event display) async {
final hasText =
!display.redacted &&
display.type == EventTypes.Message &&
!display.hasAttachment;
final canSave = !display.redacted && display.hasAttachment;
final canDelete =
!event.redacted &&
event.status.isSent &&
event.canRedact &&
event.room.canRedact;
final failed = event.status.isError;
final action = await showModalBottomSheet<String>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (failed) ...[
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
onTap: () => Navigator.pop(sheet, 'retry'),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
onTap: () => Navigator.pop(sheet, 'cancel'),
),
],
if (hasText)
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy text'),
onTap: () => Navigator.pop(sheet, 'copy'),
),
if (canSave)
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Save'),
onTap: () => Navigator.pop(sheet, 'save'),
),
if (canDelete)
ListTile(
leading: Icon(Icons.delete_outline, color: context.warp.danger),
title: Text(
'Delete',
style: TextStyle(color: context.warp.danger),
),
onTap: () => Navigator.pop(sheet, 'redact'),
),
const SizedBox(height: 8),
],
),
),
);
if (!context.mounted) return;
try {
switch (action) {
case 'retry':
await event.sendAgain();
case 'cancel':
await event.cancelSend();
case 'copy':
await Clipboard.setData(
ClipboardData(
text: display.calcLocalizedBodyFallback(
_i18n,
hideReply: true,
plaintextBody: false,
),
),
);
case 'save':
await saveAttachment(context, display);
case 'redact':
final ok = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('Delete message?'),
content: const Text(
'It is removed for everyone in the room. This can\'t be '
'undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialog, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: context.warp.danger,
),
onPressed: () => Navigator.pop(dialog, true),
child: const Text('Delete'),
),
],
),
);
if (ok == true) await event.redactEvent();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(describeError(e))));
}
}
}
}
/// Under a group: the time, "edited", and for the user's own messages
/// whether it is sending, sent, read, or failed.
class _Status extends StatelessWidget {
const _Status({
required this.event,
required this.edited,
required this.mine,
required this.read,
});
final Event event;
final bool edited;
final bool mine;
final bool read;
@override
Widget build(BuildContext context) {
final c = context.warp;
final style = Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: c.inkMuted);
final status = event.status;
final parts = <InlineSpan>[
TextSpan(text: formatTime(event.originServerTs)),
if (edited) const TextSpan(text: ' · edited'),
];
if (mine) {
if (status.isError) {
parts.add(
TextSpan(
text: ' · Not sent. Hold for Retry',
style: style?.copyWith(color: c.danger),
),
);
} else if (status.isSending) {
parts.add(const TextSpan(text: ' · Sending'));
} else if (read) {
parts.add(
TextSpan(
text: ' · Read',
style: style?.copyWith(color: c.tealText),
),
);
} else {
parts.add(const TextSpan(text: ' · Sent'));
}
}
return Text.rich(TextSpan(children: parts), style: style);
}
}
class _MessageBody extends StatelessWidget {
const _MessageBody({
required this.event,
required this.original,
required this.mine,
});
/// The event as shown: the latest edit of [original].
final Event event;
final Event original;
final bool mine;
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
final muted = text.bodyLarge?.copyWith(
color: c.inkMuted,
fontStyle: FontStyle.italic,
);
if (event.redacted) {
return Text('Message deleted', style: muted);
}
if (event.type == EventTypes.Encrypted) {
return _UndecryptableBody(event: event);
}
switch (event.messageType) {
case MessageTypes.Image:
case MessageTypes.Sticker:
return _ImageBody(event: event);
case MessageTypes.File:
case MessageTypes.Video:
case MessageTypes.Audio:
return _FileBody(event: event);
case MessageTypes.BadEncrypted:
return _UndecryptableBody(event: event);
}
final body = event.calcLocalizedBodyFallback(
_i18n,
hideReply: true,
plaintextBody: false,
);
final style = event.messageType == MessageTypes.Emote
? text.bodyLarge?.copyWith(fontStyle: FontStyle.italic)
: event.messageType == MessageTypes.Notice
? text.bodyLarge?.copyWith(color: c.inkMuted)
: text.bodyLarge;
final emote = event.messageType == MessageTypes.Emote
? '* ${event.senderFromMemoryOrFallback.calcDisplayname()} '
: '';
return LinkifiedText('$emote$body', style: style);
}
}
/// A message this session has no key for: why, in words, rather than just
/// "encrypted".
class _UndecryptableBody extends StatelessWidget {
const _UndecryptableBody({required this.event});
final Event event;
@override
Widget build(BuildContext context) {
final c = context.warp;
final client = event.room.client;
final String reason;
if (!client.encryptionEnabled) {
reason = "Encrypted. This app can't decrypt messages.";
} else if (client.isUnknownSession) {
reason =
"Can't decrypt this yet. Verify this session in Settings to read "
'older messages.';
} else if (event.content['can_request_session'] == true) {
reason =
'Waiting for the key to this message. It may have been sent '
'before this session signed in.';
} else {
reason = "Couldn't decrypt this message.";
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_clock_outlined, size: 16, color: c.inkMuted),
const SizedBox(width: 6),
Flexible(
child: Text(
reason,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: c.inkMuted,
fontStyle: FontStyle.italic,
),
),
),
],
);
}
}
class _ImageBody extends StatefulWidget {
const _ImageBody({required this.event});
final Event event;
@override
State<_ImageBody> createState() => _ImageBodyState();
}
class _ImageBodyState extends State<_ImageBody> {
late Future<Uint8List?> _bytes = Media.attachment(
widget.event,
thumbnail: true,
);
@override
Widget build(BuildContext context) {
final c = context.warp;
final info = widget.event.infoMap;
final w = (info['w'] as num?)?.toDouble();
final h = (info['h'] as num?)?.toDouble();
final maxWidth = MediaQuery.sizeOf(context).width * 0.66;
var width = maxWidth.clamp(120.0, 320.0);
var height = width * 0.75;
if (w != null && h != null && w > 0 && h > 0) {
height = (width * h / w).clamp(80.0, 360.0);
if (h > w) width = (height * w / h).clamp(80.0, width);
}
return FutureBuilder<Uint8List?>(
future: _bytes,
builder: (context, snapshot) {
final data = snapshot.data;
final placeholder = Container(
width: width,
height: height,
color: c.bubbleOther,
alignment: Alignment.center,
child: snapshot.connectionState == ConnectionState.done
? IconButton(
tooltip: 'Retry',
icon: Icon(Icons.broken_image_outlined, color: c.inkMuted),
onPressed: () => setState(() {
Media.evict(widget.event);
_bytes = Media.attachment(widget.event, thumbnail: true);
}),
)
: const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
if (data == null) return placeholder;
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ImageViewer(event: widget.event)),
),
child: Image.memory(
data,
width: width,
height: height,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => placeholder,
),
);
},
);
}
}
class _FileBody extends StatefulWidget {
const _FileBody({required this.event});
final Event event;
@override
State<_FileBody> createState() => _FileBodyState();
}
class _FileBodyState extends State<_FileBody> {
bool _saving = false;
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
final event = widget.event;
final size = event.infoMap['size'];
final icon = switch (event.messageType) {
MessageTypes.Video => Icons.movie_outlined,
MessageTypes.Audio => Icons.audiotrack_outlined,
_ => Icons.insert_drive_file_outlined,
};
return InkWell(
onTap: _saving
? null
: () async {
setState(() => _saving = true);
try {
await saveAttachment(context, event);
} finally {
if (mounted) setState(() => _saving = false);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(10),
),
child: _saving
? const Padding(
padding: EdgeInsets.all(10),
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(icon, color: c.tealText),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: text.titleSmall,
),
Text(
[
if (size is int) formatBytes(size),
'Tap to save',
].join(' · '),
style: text.labelSmall,
),
],
),
),
],
),
);
}
}
/// Downloads (and decrypts) an attachment and asks where to save it.
Future<void> saveAttachment(BuildContext context, Event event) async {
final messenger = ScaffoldMessenger.of(context);
try {
final file = await event.downloadAndDecryptAttachment();
final saved = await FilePicker.saveFile(
fileName: file.name,
bytes: file.bytes,
);
if (saved != null) {
messenger.showSnackBar(SnackBar(content: Text('Saved ${file.name}')));
}
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text(describeError(e))));
}
}
/// An image full screen, pinch to zoom.
class ImageViewer extends StatelessWidget {
const ImageViewer({super.key, required this.event});
final Event event;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
shape: const Border(),
title: Text(
event.body,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
actions: [
IconButton(
tooltip: 'Save',
icon: const Icon(Icons.download_outlined),
onPressed: () => saveAttachment(context, event),
),
],
),
body: FutureBuilder<Uint8List?>(
future: Media.attachment(event),
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) {
return Center(
child: snapshot.connectionState == ConnectionState.done
? const Text(
"Couldn't load the image.",
style: TextStyle(color: Colors.white),
)
: const CircularProgressIndicator(),
);
}
return InteractiveViewer(
maxScale: 6,
child: Center(child: Image.memory(data)),
);
},
),
);
}
}
/// Text with web links that open in the browser.
class LinkifiedText extends StatefulWidget {
const LinkifiedText(this.text, {super.key, this.style});
final String text;
final TextStyle? style;
static final _url = RegExp(
r'''\bhttps?://[^\s<>"']+[^\s<>"'.,;:!?)\]]''',
caseSensitive: false,
);
@override
State<LinkifiedText> createState() => _LinkifiedTextState();
}
class _LinkifiedTextState extends State<LinkifiedText> {
final _recognizers = <TapGestureRecognizer>[];
@override
void dispose() {
for (final r in _recognizers) {
r.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final r in _recognizers) {
r.dispose();
}
_recognizers.clear();
final text = widget.text;
final matches = LinkifiedText._url.allMatches(text).toList();
if (matches.isEmpty) return Text(text, style: widget.style);
final link = widget.style?.copyWith(
color: context.warp.tealText,
decoration: TextDecoration.underline,
decorationColor: context.warp.tealText,
);
final spans = <InlineSpan>[];
var at = 0;
for (final m in matches) {
if (m.start > at) spans.add(TextSpan(text: text.substring(at, m.start)));
final url = m.group(0)!;
final recognizer = TapGestureRecognizer()
..onTap = () =>
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
_recognizers.add(recognizer);
spans.add(TextSpan(text: url, style: link, recognizer: recognizer));
at = m.end;
}
if (at < text.length) spans.add(TextSpan(text: text.substring(at)));
return Text.rich(TextSpan(children: spans), style: widget.style);
}
}