feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention

This commit is contained in:
randogoth 2026-09-27 11:07:11 +03:00
parent eaaa3f2ede
commit c693e6fcb9
27 changed files with 1190 additions and 592 deletions

View file

@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
class MessageList extends ConsumerWidget {
final String folder;
final Future<void> Function() onRefresh;
const MessageList({
super.key,
required this.folder,
required this.onRefresh,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
final messages = ref.watch(messagesForFolderProvider(folder));
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]),
itemCount: messages.length,
),
);

View file

@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageTile extends ConsumerWidget {
final String folder;
final MailRecord record;
const MessageTile({super.key, required this.record});
const MessageTile({super.key, required this.folder, required this.record});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider);
final opened = ref.read(clientProvider).describe(record);
final appColors = Theme.of(context).extension<AppColors>()!;
@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget {
} else {
who = "?";
}
final unread = folder == "inbox" && !store.isRead(record.id);
final unread = folder != "sent" && !store.isRead(record.id);
final errorText = opened.error != null ? "<unreadable>" : null;
final subject =
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget {
splashColor: appColors.highlight,
focusColor: appColors.cardFill,
onTap: () {
if (folder == "inbox") {
if (folder != "sent") {
store.markRead(record.id);
ref.read(revisionProvider.notifier).bump();
}

View file

@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.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(Uint8List senderKey)? onNameSender;
const MessageView({
super.key,
required this.folder,
required this.record,
required this.opened,
this.onNameSender,
@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget {
);
}
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 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;
@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget {
: "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${b32encode(senderKey)}"),
@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget {
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")
if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept")
_field(context, entry.key, entry.value),
if (!isSent && replyTo != null)
Row(
@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget {
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),