feat(chat): add "Mark as Unread" action and unread messages divider

- Add "Mark as Unread" option to message context menu in both
  contact and channel chats
- Show "New messages" divider line between read and unread messages
- Add setContactUnreadCount/setChannelUnreadCount methods to connector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dmitry Polshakov
2026-04-25 09:05:28 +03:00
parent 3ea2e4763e
commit 00e4f52d75
6 changed files with 165 additions and 8 deletions
+21
View File
@@ -658,6 +658,27 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void setContactUnreadCount(String contactKeyHex, int count) {
_contactUnreadCount[contactKeyHex] = count;
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
notifyListeners();
}
void setChannelUnreadCount(int channelIndex, int count) {
final channel = _findChannelByIndex(channelIndex);
if (channel != null) {
channel.unreadCount = count;
unawaited(
_channelStore.saveChannels(
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
notifyListeners();
}
}
void markChannelRead(int channelIndex) { void markChannelRead(int channelIndex) {
final channel = _findChannelByIndex(channelIndex); final channel = _findChannelByIndex(channelIndex);
if (channel != null && channel.unreadCount > 0) { if (channel != null && channel.unreadCount > 0) {
+2
View File
@@ -824,6 +824,8 @@
} }
} }
}, },
"chat_markAsUnread": "Mark as Unread",
"chat_newMessages": "New messages",
"chat_openLink": "Open Link?", "chat_openLink": "Open Link?",
"chat_openLinkConfirmation": "Do you want to open this link in your browser?", "chat_openLinkConfirmation": "Do you want to open this link in your browser?",
"chat_open": "Open", "chat_open": "Open",
+6
View File
@@ -1527,6 +1527,12 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Unread: $count'; return 'Unread: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Open Link?'; String get chat_openLink => 'Open Link?';
+53 -3
View File
@@ -32,6 +32,7 @@ import '../widgets/message_translation_button.dart';
import '../widgets/message_status_icon.dart'; import '../widgets/message_status_icon.dart';
import '../widgets/radio_stats_entry.dart'; import '../widgets/radio_stats_entry.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
@@ -60,12 +61,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false; bool _channelSkipNextBottomSnap = false;
String? _unreadDividerMessageId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange); _textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
@@ -74,12 +77,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final unread = widget.initialUnreadCount; final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
ChannelMessage? anchor; ChannelMessage? anchor;
if (settings.jumpToOldestUnread && unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread); anchor = _findOldestUnreadChannelAnchor(messages, unread);
} }
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
});
connector.setActiveChannel(idx); connector.setActiveChannel(idx);
_connector = connector; _connector = connector;
if (anchor != null) { if (anchor != null && settings.jumpToOldestUnread) {
_channelSkipNextBottomSnap = true; _channelSkipNextBottomSnap = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
@@ -112,6 +118,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return oldest; return oldest;
} }
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() { void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) { if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen(); _scrollController.handleKeyboardOpen();
@@ -133,6 +146,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void dispose() { void dispose() {
_connector?.setActiveChannel(null); _connector?.setActiveChannel(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -331,6 +345,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (!_messageKeys.containsKey(message.messageId)) { if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey(); _messageKeys[message.messageId] = GlobalKey();
} }
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId ==
_unreadDividerMessageId;
return Container( return Container(
key: _messageKeys[message.messageId]!, key: _messageKeys[message.messageId]!,
child: Builder( child: Builder(
@@ -339,10 +357,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>( .select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
return _buildMessageBubble( final bubble = _buildMessageBubble(
message, message,
textScale, textScale,
); );
if (isUnreadAnchor) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const UnreadDivider(),
bubble,
],
);
}
return bubble;
}, },
), ),
); );
@@ -362,6 +390,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
void _markAsUnread(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>();
final messages = connector.getChannelMessages(widget.channel);
var count = 0;
var found = false;
for (final m in messages) {
if (m.messageId == message.messageId) found = true;
if (found && !m.isOutgoing) count++;
}
connector.setChannelUnreadCount(widget.channel.index, count);
Navigator.pop(context);
}
Widget _buildMessageBubble(ChannelMessage message, double textScale) { Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing; final enableTracing = settingsService.settings.enableMessageTracing;
@@ -1288,6 +1329,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_copyMessageText(message.text); _copyMessageText(message.text);
}, },
), ),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete), title: Text(context.l10n.common_delete),
+51 -5
View File
@@ -44,6 +44,7 @@ import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
@@ -68,6 +69,7 @@ class _ChatScreenState extends State<ChatScreen> {
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
Message? _pendingUnreadScrollTarget; Message? _pendingUnreadScrollTarget;
String? _unreadDividerMessageId;
DateTime? _lastTextSendAt; DateTime? _lastTextSendAt;
@override @override
@@ -75,6 +77,7 @@ class _ChatScreenState extends State<ChatScreen> {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange); _textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
@@ -83,13 +86,18 @@ class _ChatScreenState extends State<ChatScreen> {
final unread = widget.initialUnreadCount; final unread = widget.initialUnreadCount;
final messages = connector.getMessages(widget.contact); final messages = connector.getMessages(widget.contact);
Message? anchor; Message? anchor;
if (settings.jumpToOldestUnread && unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadAnchor(messages, unread); anchor = _findOldestUnreadAnchor(messages, unread);
} }
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
if (anchor != null && settings.jumpToOldestUnread) {
_pendingUnreadScrollTarget = anchor;
}
});
connector.setActiveContact(keyHex); connector.setActiveContact(keyHex);
_connector = connector; _connector = connector;
if (anchor != null) { if (anchor != null && settings.jumpToOldestUnread) {
setState(() => _pendingUnreadScrollTarget = anchor);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_scrollController.jumpToEstimatedOffset( _scrollController.jumpToEstimatedOffset(
@@ -128,6 +136,13 @@ class _ChatScreenState extends State<ChatScreen> {
return oldest; return oldest;
} }
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() { void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) { if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen(); _scrollController.handleKeyboardOpen();
@@ -149,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void dispose() { void dispose() {
_connector?.setActiveContact(null); _connector?.setActiveContact(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -498,10 +514,18 @@ class _ChatScreenState extends State<ChatScreen> {
onRetryReaction: (msg, emoji) => onRetryReaction: (msg, emoji) =>
_sendReaction(msg, contact, emoji), _sendReaction(msg, contact, emoji),
); );
final isUnreadAnchor = _unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
final child = isUnreadAnchor
? Column(
mainAxisSize: MainAxisSize.min,
children: [const UnreadDivider(), bubble],
)
: bubble;
if (identical(message, _pendingUnreadScrollTarget)) { if (identical(message, _pendingUnreadScrollTarget)) {
return KeyedSubtree(key: _unreadScrollKey, child: bubble); return KeyedSubtree(key: _unreadScrollKey, child: child);
} }
return bubble; return child;
}, },
); );
}, },
@@ -509,6 +533,19 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
void _markAsUnread(Message message) {
final connector = context.read<MeshCoreConnector>();
final messages = connector.getMessages(widget.contact);
var count = 0;
var found = false;
for (final m in messages) {
if (m.messageId == message.messageId) found = true;
if (found && !m.isOutgoing && !m.isCli) count++;
}
connector.setContactUnreadCount(widget.contact.publicKeyHex, count);
Navigator.pop(context);
}
Widget _buildInputBar(MeshCoreConnector connector) { Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes(); final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1465,6 +1502,15 @@ class _ChatScreenState extends State<ChatScreen> {
_copyMessageText(message.text); _copyMessageText(message.text);
}, },
), ),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete), title: Text(context.l10n.common_delete),
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class UnreadDivider extends StatelessWidget {
const UnreadDivider({super.key});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(child: Divider(color: color)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.chat_newMessages,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Divider(color: color)),
],
),
);
}
}