feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
|
|
@ -1,39 +1,190 @@
|
|||
import "package:flutter/widgets.dart";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/message_detail/message_detail.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/attachment/attachment_list.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_body.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_sender.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/proto.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
class MessageView extends StatelessWidget {
|
||||
final TMMessageDetail message;
|
||||
/// 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 MailRecord record;
|
||||
final OpenedRecord opened;
|
||||
final void Function(Uint8List senderKey)? onNameSender;
|
||||
|
||||
const MessageView({super.key, required this.message});
|
||||
const MessageView({
|
||||
super.key,
|
||||
required this.record,
|
||||
required this.opened,
|
||||
this.onNameSender,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 folder = ref.watch(folderProvider);
|
||||
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 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 subject =
|
||||
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, child) => MessageSender(
|
||||
senderName: message.from["name"] as String,
|
||||
senderAddress: message.from["address"] as String,
|
||||
receiverAddress: ref.read(emailProvider) as String,
|
||||
date: message.createdAt,
|
||||
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 (!isSent && senderKey != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(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")
|
||||
_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"),
|
||||
),
|
||||
),
|
||||
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),
|
||||
MessageBody(
|
||||
html: message.html[0],
|
||||
),
|
||||
message.hasAttachments
|
||||
? AttachmentList(
|
||||
attachments: message.attachments!,
|
||||
)
|
||||
: const SizedBox.shrink()
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue