mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-26 05:26:37 +10:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?';
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user