mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-30 06:00:30 +10:00
Merge pull request #414 from Diadlo/fix/jump_to_unread
Improve work with unread messages
This commit is contained in:
@@ -32,13 +32,19 @@ import '../widgets/message_translation_button.dart';
|
||||
import '../widgets/message_status_icon.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
import '../widgets/translated_message_content.dart';
|
||||
import '../widgets/unread_divider.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
final int initialUnreadCount;
|
||||
|
||||
const ChannelChatScreen({super.key, required this.channel});
|
||||
const ChannelChatScreen({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.initialUnreadCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||
@@ -55,32 +61,42 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
MeshCoreConnector? _connector;
|
||||
DateTime? _lastChannelSendAt;
|
||||
bool _channelSkipNextBottomSnap = false;
|
||||
String? _unreadDividerMessageId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final idx = widget.channel.index;
|
||||
final unread = connector.getUnreadCountForChannelIndex(idx);
|
||||
final unread = widget.initialUnreadCount;
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
ChannelMessage? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadChannelAnchor(
|
||||
connector.getChannelMessages(widget.channel),
|
||||
unread,
|
||||
);
|
||||
if (unread > 0) {
|
||||
anchor = _findOldestUnreadChannelAnchor(messages, unread);
|
||||
}
|
||||
setState(() {
|
||||
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
|
||||
});
|
||||
connector.setActiveChannel(idx);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
if (anchor != null && settings.jumpToOldestUnread) {
|
||||
_channelSkipNextBottomSnap = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_scrollToMessage(anchor!.messageId);
|
||||
_scrollController.jumpToEstimatedOffset(
|
||||
unreadCount: unread,
|
||||
totalMessages: messages.length,
|
||||
onJumped: () {
|
||||
if (!mounted) return;
|
||||
_scrollToMessage(anchor!.messageId);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -102,6 +118,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _clearDividerAtBottom() {
|
||||
if (!_scrollController.showJumpToBottom.value &&
|
||||
_unreadDividerMessageId != null) {
|
||||
setState(() => _unreadDividerMessageId = null);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -123,6 +146,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.setActiveChannel(null);
|
||||
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
@@ -321,6 +345,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
final isUnreadAnchor =
|
||||
_unreadDividerMessageId != null &&
|
||||
message.messageId == _unreadDividerMessageId;
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: Builder(
|
||||
@@ -329,10 +356,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
return _buildMessageBubble(
|
||||
final bubble = _buildMessageBubble(
|
||||
message,
|
||||
textScale,
|
||||
);
|
||||
if (isUnreadAnchor) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const UnreadDivider(), bubble],
|
||||
);
|
||||
}
|
||||
return bubble;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -352,6 +386,18 @@ 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);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||
@@ -1288,6 +1334,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_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(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: Text(context.l10n.common_delete),
|
||||
|
||||
@@ -492,13 +492,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final unread =
|
||||
connector.getUnreadCountForChannelIndex(channel.index);
|
||||
connector.markChannelRead(channel.index);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
builder: (context) => ChannelChatScreen(
|
||||
channel: channel,
|
||||
initialUnreadCount: unread,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,12 +44,18 @@ import '../widgets/translated_message_content.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../widgets/unread_divider.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final int initialUnreadCount;
|
||||
|
||||
const ChatScreen({super.key, required this.contact});
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.contact,
|
||||
this.initialUnreadCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -63,6 +69,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
bool _isLoadingOlder = false;
|
||||
MeshCoreConnector? _connector;
|
||||
Message? _pendingUnreadScrollTarget;
|
||||
String? _unreadDividerMessageId;
|
||||
DateTime? _lastTextSendAt;
|
||||
|
||||
@override
|
||||
@@ -70,34 +77,47 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
super.initState();
|
||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final keyHex = widget.contact.publicKeyHex;
|
||||
final unread = connector.getUnreadCountForContactKey(keyHex);
|
||||
final unread = widget.initialUnreadCount;
|
||||
final messages = connector.getMessages(widget.contact);
|
||||
Message? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadAnchor(
|
||||
connector.getMessages(widget.contact),
|
||||
unread,
|
||||
);
|
||||
if (unread > 0) {
|
||||
anchor = _findOldestUnreadAnchor(messages, unread);
|
||||
}
|
||||
setState(() {
|
||||
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
|
||||
if (anchor != null && settings.jumpToOldestUnread) {
|
||||
_pendingUnreadScrollTarget = anchor;
|
||||
}
|
||||
});
|
||||
connector.setActiveContact(keyHex);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
setState(() => _pendingUnreadScrollTarget = anchor);
|
||||
if (anchor != null && settings.jumpToOldestUnread) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final ctx = _unreadScrollKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
alignment: 0.15,
|
||||
);
|
||||
}
|
||||
setState(() => _pendingUnreadScrollTarget = null);
|
||||
_scrollController.jumpToEstimatedOffset(
|
||||
unreadCount: unread,
|
||||
totalMessages: messages.length,
|
||||
onJumped: () async {
|
||||
if (!mounted) return;
|
||||
final ctx = _unreadScrollKey.currentContext;
|
||||
if (ctx != null) {
|
||||
await Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
alignment: 0.15,
|
||||
);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _pendingUnreadScrollTarget = null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -116,6 +136,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _clearDividerAtBottom() {
|
||||
if (!_scrollController.showJumpToBottom.value &&
|
||||
_unreadDividerMessageId != null) {
|
||||
setState(() => _unreadDividerMessageId = null);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -137,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.setActiveContact(null);
|
||||
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
@@ -486,10 +514,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
onRetryReaction: (msg, 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)) {
|
||||
return KeyedSubtree(key: _unreadScrollKey, child: bubble);
|
||||
return KeyedSubtree(key: _unreadScrollKey, child: child);
|
||||
}
|
||||
return bubble;
|
||||
return child;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -497,6 +534,18 @@ 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);
|
||||
}
|
||||
|
||||
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -1320,11 +1369,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
void _openChat(BuildContext context, Contact contact) {
|
||||
// Check if this is a repeater
|
||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||
connector.markContactRead(contact.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChatScreen(contact: contact, initialUnreadCount: unread),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1461,6 +1514,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_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(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: Text(context.l10n.common_delete),
|
||||
|
||||
@@ -930,10 +930,18 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
} else if (contact.type == advTypeRoom) {
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
||||
} else {
|
||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final unread =
|
||||
connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||
connector.markContactRead(contact.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
contact: contact,
|
||||
initialUnreadCount: unread,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -988,7 +996,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
builder: (context) => RoomLoginDialog(
|
||||
room: room,
|
||||
onLogin: (password, isAdmin) {
|
||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final unread =
|
||||
connector.getUnreadCountForContactKey(room.publicKeyHex);
|
||||
connector.markContactRead(room.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
password: password,
|
||||
isAdmin: isAdmin,
|
||||
)
|
||||
: ChatScreen(contact: room),
|
||||
: ChatScreen(
|
||||
contact: room,
|
||||
initialUnreadCount: unread,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1391,11 +1391,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
room: room,
|
||||
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
||||
onLogin: (password, _) {
|
||||
// Navigate to chat screen after successful login
|
||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final unread = connector.getUnreadCountForContactKey(
|
||||
room.publicKeyHex,
|
||||
);
|
||||
connector.markContactRead(room.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChatScreen(contact: room, initialUnreadCount: unread),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1456,11 +1462,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (!contact.isActive) {
|
||||
connector.importDiscoveredContact(contact);
|
||||
}
|
||||
final unread = connector.getUnreadCountForContactKey(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(contact: contact),
|
||||
builder: (context) => ChatScreen(
|
||||
contact: contact,
|
||||
initialUnreadCount: unread,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user