217 lines
7.8 KiB
Dart
217 lines
7.8 KiB
Dart
|
|
import "package:auto_route/auto_route.dart";
|
|
import "package:flutter/material.dart";
|
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
|
|
import "package:smol_mail/data/providers/providers.dart";
|
|
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
|
import "package:smol_mail/shared/utils/format.dart";
|
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
|
import "package:smol_mail/smol/client.dart";
|
|
import "package:smol_mail/smol/store.dart";
|
|
import "package:smol_mail/smol/ui.dart";
|
|
|
|
/// The opened message: trust row (fingerprint and how the sender's key is
|
|
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
|
|
class MessageView extends ConsumerWidget {
|
|
final String folder;
|
|
final MailRecord record;
|
|
final OpenedRecord opened;
|
|
final void Function(String senderKey)? onNameSender;
|
|
|
|
const MessageView({
|
|
super.key,
|
|
required this.folder,
|
|
required this.record,
|
|
required this.opened,
|
|
this.onNameSender,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
if (opened.error != null) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text("<unreadable: ${opened.error}>",
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
],
|
|
);
|
|
}
|
|
|
|
final store = ref.watch(storeProvider);
|
|
final client = ref.read(clientProvider);
|
|
|
|
// Sent copies carry no signature from a third party; the trust row then
|
|
// just says what they are.
|
|
final isSent = folder == "sent";
|
|
final isRequest = folder == "requests";
|
|
final senderKey = opened.sender;
|
|
final known = senderKey == null ? null : store.addressForKey(senderKey);
|
|
final contact = known == null ? null : store.contact(known);
|
|
final verifiedSender = contact?.verified ?? false;
|
|
final replyTo = isSent ? null : client.replyAddress(opened);
|
|
final accepted = known == null ? null : store.accepted(known);
|
|
|
|
final subject =
|
|
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(subject, style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 14),
|
|
if (isSent) _badge(context, "your own sealed copy") else
|
|
_badge(
|
|
context,
|
|
verifiedSender
|
|
? "verified sender key"
|
|
: known != null
|
|
? "key pinned on first use"
|
|
: "key bound to no address",
|
|
alert: known == null,
|
|
),
|
|
if (isRequest) ...[
|
|
const SizedBox(height: 10),
|
|
_badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true),
|
|
],
|
|
if (!isSent && senderKey != null) ...[
|
|
const SizedBox(height: 10),
|
|
_keyBlock(context, "${fingerprint(senderKey)}\n$senderKey"),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_field(context, isSent ? "To" : "From",
|
|
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
|
|
_field(context, "Date", formatTime(opened.time)),
|
|
for (final entry in opened.fields.entries)
|
|
if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept")
|
|
_field(context, entry.key, entry.value),
|
|
if (!isSent && replyTo != null)
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _field(context, "Reply-To", replyTo.short)),
|
|
TextButton(
|
|
onPressed: () async {
|
|
try {
|
|
await client.saveReplyAddress(replyTo, senderKey!);
|
|
ref.read(revisionProvider.notifier).bump();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text("Address saved for this sender")),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, err.toString());
|
|
}
|
|
}
|
|
},
|
|
child: const Text("Save"),
|
|
),
|
|
],
|
|
),
|
|
if (!isSent && senderKey != null && onNameSender != null)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton(
|
|
onPressed: () => onNameSender!(senderKey),
|
|
child: const Text("Name this sender"),
|
|
),
|
|
),
|
|
if (isRequest && known != null && !(accepted?.active ?? false))
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
icon: const Icon(Icons.check_circle_outline, size: 18),
|
|
label: const Text("Accept sender"),
|
|
onPressed: () async {
|
|
try {
|
|
await client.acceptContact(known);
|
|
ref.read(revisionProvider.notifier).bump();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text("$known accepted")),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (context.mounted) showErrorSnackBar(context, err.toString());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 20),
|
|
SelectableText(
|
|
opened.body,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 25),
|
|
if (!isSent && senderKey != null)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.reply, size: 18),
|
|
label: const Text("Reply"),
|
|
onPressed: () => AutoRouter.of(context).push(
|
|
ComposeRoute(
|
|
to: known ?? replyTo?.short ?? "",
|
|
subject: replySubject(opened.subject),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _badge(BuildContext context, String text, {bool alert = false}) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final background = alert ? scheme.errorContainer : scheme.primaryContainer;
|
|
final foreground = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelSmall!
|
|
.copyWith(color: foreground, fontWeight: FontWeight.w500),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Technical detail (fingerprint, full key) — quiet by design, so it never
|
|
/// competes with the subject or body for attention.
|
|
Widget _keyBlock(BuildContext context, String text) => Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
|
fontSize: 12,
|
|
height: 1.5,
|
|
fontWeight: FontWeight.normal,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
|
|
Widget _field(BuildContext context, String label, String value) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 90,
|
|
child: Text(label, style: Theme.of(context).textTheme.labelSmall),
|
|
),
|
|
Expanded(
|
|
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|