diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 70d14750..80ceda50 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -86,25 +86,59 @@ class Chat extends StatelessWidget { ? chatTabsStore!.mergedScrollController : chatStore.scrollController; - return FrostyScrollbar( - controller: scrollController, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - bottom: - chatStore.bottomBarHeight + bottomPadding, - ), - child: Observer( - builder: (context) { - return _isMerged - ? _buildMergedList( - scrollController, - bottomPadding, - ) - : _buildNormalList( - scrollController, - bottomPadding, - ); - }, + // Freeze the message list while a finger rests on a + // message so a long-press target can't scroll out + // from under it (which would dispose its recognizer + // mid-press). The store resumes flowing as soon as + // the pointer moves like a scroll, so scrolling is + // never interrupted — only a stationary hold pauses. + void onDown(PointerDownEvent e) => _isMerged + ? chatTabsStore!.onMessageListPointerDown( + e.pointer, + e.position, + ) + : chatStore.onMessageListPointerDown( + e.pointer, + e.position, + ); + void onMove(PointerMoveEvent e) => _isMerged + ? chatTabsStore!.onMessageListPointerMove( + e.pointer, + e.position, + ) + : chatStore.onMessageListPointerMove( + e.pointer, + e.position, + ); + void onUp(int pointer) => _isMerged + ? chatTabsStore!.onMessageListPointerUp(pointer) + : chatStore.onMessageListPointerUp(pointer); + + return Listener( + onPointerDown: onDown, + onPointerMove: onMove, + onPointerUp: (e) => onUp(e.pointer), + onPointerCancel: (e) => onUp(e.pointer), + child: FrostyScrollbar( + controller: scrollController, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + bottom: + chatStore.bottomBarHeight + bottomPadding, + ), + child: Observer( + builder: (context) { + return _isMerged + ? _buildMergedList( + scrollController, + bottomPadding, + ) + : _buildNormalList( + scrollController, + bottomPadding, + ); + }, + ), ), ); }, @@ -145,19 +179,25 @@ class Chat extends StatelessWidget { return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only( - bottom: chatStore.bottomBarHeight + bottomPadding, - ), + EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), ), addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages[ - chatStore.renderMessages.length - 1 - index], - chatStore: chatStore, - ), + itemBuilder: (context, index) { + final ircMessage = chatStore + .renderMessages[chatStore.renderMessages.length - 1 - index]; + // Key by message identity so the list reconciles by message rather + // than by slot. This keeps each message's element (and its gesture + // recognizers) bound to the same message when new messages arrive + // mid-interaction, fixing long-press landing on the wrong message. + return ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: chatStore, + ); + }, ); } @@ -166,25 +206,22 @@ class Chat extends StatelessWidget { double bottomPadding, ) { final mergedMessages = chatTabsStore!.mergedMessages; - final channelIdToUserTwitch = - chatTabsStore!.mergedChannelIdToUserTwitch; + final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; final currentChannelId = chatTabsStore!.activeTab.channelId; return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only( - bottom: chatStore.bottomBarHeight + bottomPadding, - ), + EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), ), addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, itemBuilder: (context, index) { - final merged = - mergedMessages[mergedMessages.length - 1 - index]; + final merged = mergedMessages[mergedMessages.length - 1 - index]; return ChatMessage( + key: ObjectKey(merged.ircMessage), ircMessage: merged.ircMessage, chatStore: merged.chatStore, inputChatStore: chatStore, @@ -218,8 +255,7 @@ class Chat extends StatelessWidget { right: 4, bottom: chatStore.bottomBarHeight + - (chatStore.assetsStore.showEmoteMenu || - isHorizontalLandscape + (chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape ? 0 : MediaQuery.of(context).padding.bottom), ), @@ -243,17 +279,13 @@ class Chat extends StatelessWidget { ? null : ElevatedButton.icon( onPressed: onResume, - icon: const Icon( - Icons.arrow_downward_rounded, - ), + icon: const Icon(Icons.arrow_downward_rounded), label: Text( bufferCount > 0 ? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}' : 'Resume scroll', style: const TextStyle( - fontFeatures: [ - FontFeature.tabularFigures(), - ], + fontFeatures: [FontFeature.tabularFigures()], ), ), ), @@ -292,17 +324,13 @@ class Chat extends StatelessWidget { child: FrostyPageView( headers: [ 'Recent', - if (chatStore.settings.showTwitchEmotes) - 'Twitch', + if (chatStore.settings.showTwitchEmotes) 'Twitch', if (chatStore.settings.show7TVEmotes) '7TV', if (chatStore.settings.showBTTVEmotes) 'BTTV', if (chatStore.settings.showFFZEmotes) 'FFZ', ], tabActions: { - if (chatStore - .assetsStore - .recentEmotes - .isNotEmpty) + if (chatStore.assetsStore.recentEmotes.isNotEmpty) 0: IconButton( onPressed: () { chatStore.assetsStore.recentEmotes.clear(); @@ -310,9 +338,7 @@ class Chat extends StatelessWidget { 'Recent emotes cleared', ); }, - icon: const Icon( - Icons.delete_outline_rounded, - ), + icon: const Icon(Icons.delete_outline_rounded), tooltip: 'Clear recent emotes', iconSize: 20, ), diff --git a/lib/screens/channel/chat/stores/chat_interaction_pause.dart b/lib/screens/channel/chat/stores/chat_interaction_pause.dart new file mode 100644 index 00000000..0d3456a9 --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_interaction_pause.dart @@ -0,0 +1,54 @@ +import 'package:flutter/gestures.dart'; + +/// Decides whether the chat message list should hold buffered messages back +/// because the user is *holding* a message (a long-press candidate). +/// +/// Flushing buffered messages grows the reverse list and pushes the pressed +/// message up and out of the build range, which disposes its gesture recognizer +/// mid-press and cancels the long-press. Freezing the list while a finger rests +/// on a message keeps it under the finger so the press lands. +/// +/// Crucially, the freeze applies *only to a stationary finger*. The moment a +/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat +/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes +/// scrolling feel like it gets cancelled. This is the same threshold the +/// long-press recognizer uses to tell a hold from a drag. +/// +/// Tracks pointers individually so multi-touch resolves correctly: the chat +/// only resumes once every stationary pointer has lifted or started moving. +class ChatInteractionPause { + /// Down position of each active pointer, by pointer id. + final _origins = {}; + + /// Pointers that have moved past the slop this gesture — i.e. are scrolling. + /// Once a pointer is here it stays until it lifts, so a drag never flips back + /// to a hold mid-gesture. + final _scrolling = {}; + + /// Whether any pointer is down and still stationary (a long-press candidate). + bool get isPaused => _origins.length > _scrolling.length; + + /// Register a pointer touching down at [position]. + void pointerDown(int pointer, Offset position) { + _origins[pointer] = position; + } + + /// Update a pointer's position; flags it as scrolling once it passes the slop. + void pointerMove(int pointer, Offset position) { + final origin = _origins[pointer]; + if (origin == null) return; + if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer); + } + + /// Register a pointer lifting off (or being cancelled). + void pointerUp(int pointer) { + _origins.remove(pointer); + _scrolling.remove(pointer); + } + + /// Force-clear all active pointers (e.g. on dispose or tab switch). + void reset() { + _origins.clear(); + _scrolling.clear(); + } +} diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 8496a6c5..1b500b72 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -12,6 +12,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; @@ -98,11 +99,11 @@ abstract class ChatStoreBase with Store { return parts.length >= 3 && _delayBypassCommands.contains(parts[2]); } - Duration get _chatDelay => Duration( - milliseconds: (settings.effectiveChatDelay * 1000).round(), - ); + Duration get _chatDelay => + Duration(milliseconds: (settings.effectiveChatDelay * 1000).round()); - bool get _hasActiveChatDelay => settings.showVideo && _chatDelay > Duration.zero; + bool get _hasActiveChatDelay => + settings.showVideo && _chatDelay > Duration.zero; final TwitchApi twitchApi; @@ -266,6 +267,21 @@ abstract class ChatStoreBase with Store { /// Forget a user's ban state after a successful unban so the action hides. void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); + + /// Freezes the rendered list while the user is touching it, so a long-press + /// target can't scroll out from under the finger and get its gesture + /// recognizer disposed mid-press. Plain field (not observable): it only gates + /// the periodic [addMessages] flush, which reads it each 200ms tick. + final _interactionPause = ChatInteractionPause(); + + /// Hooks for the message list to report touch start/move/end (see [Chat]). + void onMessageListPointerDown(int pointer, Offset position) => + _interactionPause.pointerDown(pointer, position); + void onMessageListPointerMove(int pointer, Offset position) => + _interactionPause.pointerMove(pointer, position); + void onMessageListPointerUp(int pointer) => + _interactionPause.pointerUp(pointer); + @readonly var _inputText = ''; @@ -375,16 +391,12 @@ abstract class ChatStoreBase with Store { // Normalize muted words once per settings change instead of per message // — the filter runs in the IRC hot path (50+ msgs/s in busy channels). reactions.add( - reaction( - (_) => settings.mutedWords.toList(), - (List words) { - _normalizedMutedWords = words - .map((word) => word.trim().toLowerCase()) - .where((word) => word.isNotEmpty) - .toList(); - }, - fireImmediately: true, - ), + reaction((_) => settings.mutedWords.toList(), (List words) { + _normalizedMutedWords = words + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(); + }, fireImmediately: true), ); reactions.add( @@ -616,8 +628,7 @@ abstract class ChatStoreBase with Store { final msg = parsedIRCMessage.message!.toLowerCase(); final words = settings.matchWholeWord ? msg.split(' ') : null; final muted = _normalizedMutedWords.any( - (word) => - words != null ? words.contains(word) : msg.contains(word), + (word) => words != null ? words.contains(word) : msg.contains(word), ); if (muted) continue; } @@ -1198,7 +1209,11 @@ abstract class ChatStoreBase with Store { void addMessages() { _flushDueDelayedCallbacks(); - if (!_autoScroll || messageBuffer.isEmpty) return; + // Hold messages back while the user is touching the list so the pressed + // message stays put (and alive) for the duration of a long-press. + if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) { + return; + } _messages.addAll(messageBuffer); messageBuffer.clear(); @@ -1352,7 +1367,8 @@ abstract class ChatStoreBase with Store { : null; if (toSend != null) { // Prefer the server id; fall back to any id already set, then a local one. - toSend!.tags['id'] = serverMessageId ?? + toSend!.tags['id'] = + serverMessageId ?? toSend!.tags['id'] ?? 'local-${DateTime.now().millisecondsSinceEpoch}'; messageBuffer.add(toSend!); @@ -1506,6 +1522,8 @@ abstract class ChatStoreBase with Store { void dispose() { _shouldDisconnect = true; + _interactionPause.reset(); + _clearPendingDelayedCallbacks(); _messageBufferTimer?.cancel(); diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index c5c2819d..ee095442 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -151,6 +152,19 @@ abstract class ChatTabsStoreBase with Store { /// Timer that drives merged message rendering at a fixed cadence. Timer? _mergedRenderTimer; + /// Freezes the rendered merged list while the user is touching it, so a + /// long-press target can't scroll out from under the finger. Gates the + /// periodic [_refreshMergedMessages]; see [ChatInteractionPause]. + final _interactionPause = ChatInteractionPause(); + + /// Hooks for the merged message list to report touch start/move/end (see [Chat]). + void onMessageListPointerDown(int pointer, Offset position) => + _interactionPause.pointerDown(pointer, position); + void onMessageListPointerMove(int pointer, Offset position) => + _interactionPause.pointerMove(pointer, position); + void onMessageListPointerUp(int pointer) => + _interactionPause.pointerUp(pointer); + /// Frozen snapshot of merged messages when user scrolls up. /// Prevents new messages from causing scroll jumps while reading. List? _mergedSnapshot; @@ -188,8 +202,9 @@ abstract class ChatTabsStoreBase with Store { int? tailCount, }) { final msgs = store.messages; - final start = - tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0; + final start = tailCount != null && msgs.length > tailCount + ? msgs.length - tailCount + : 0; for (var i = start; i < msgs.length; i++) { final id = msgs[i].tags['id']; if (id != null && !seenIds.add(id)) continue; @@ -225,7 +240,12 @@ abstract class ChatTabsStoreBase with Store { final seenIds = {}; for (final tab in _tabs) { if (tab.chatStore == null) continue; - _collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit); + _collectMessages( + tab.chatStore!, + recent, + seenIds, + tailCount: _mergedRenderLimit, + ); } recent.sort(_compareByTimestamp); if (recent.length > _mergedRenderLimit) { @@ -249,7 +269,9 @@ abstract class ChatTabsStoreBase with Store { /// Refreshes the rendered merged messages. Called by the render timer. @action void _refreshMergedMessages() { - if (!_mergedAutoScroll) return; + // Hold the rendered list still while the user is touching it so a + // long-press target stays put for the duration of the press. + if (!_mergedAutoScroll || _interactionPause.isPaused) return; _mergedRenderedMessages = _computeRecentMergedMessages(); } @@ -260,7 +282,8 @@ abstract class ChatTabsStoreBase with Store { for (final tab in _tabs) { if (tab.chatStore != null) { total += - tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length; + tab.chatStore!.messages.length + + tab.chatStore!.messageBuffer.length; } } return total; @@ -718,6 +741,7 @@ abstract class ChatTabsStoreBase with Store { void dispose() { _mergedRenderTimer?.cancel(); _mergedRenderTimer = null; + _interactionPause.reset(); for (final tab in _tabs) { tab.chatStore?.dispose(); } diff --git a/test/screens/channel/chat/chat_interaction_pause_test.dart b/test/screens/channel/chat/chat_interaction_pause_test.dart new file mode 100644 index 00000000..f535c5a4 --- /dev/null +++ b/test/screens/channel/chat/chat_interaction_pause_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; + +void main() { + late ChatInteractionPause pause; + + setUp(() => pause = ChatInteractionPause()); + + // A move clearly past the touch slop (a scroll), and one well within it. + final beyondSlop = Offset(0, kTouchSlop + 10); + final withinSlop = Offset(0, kTouchSlop - 5); + + test('is not paused initially', () { + expect(pause.isPaused, isFalse); + }); + + test('is paused while a stationary pointer is down (long-press candidate)', () { + pause.pointerDown(1, Offset.zero); + + expect(pause.isPaused, isTrue); + }); + + test('resumes once the pointer is lifted', () { + pause.pointerDown(1, Offset.zero); + pause.pointerUp(1); + + expect(pause.isPaused, isFalse); + }); + + test('resumes when the pointer moves beyond the touch slop (scrolling)', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, beyondSlop); + + // The finger is dragging — this is a scroll, not a hold, so the chat must + // keep flowing rather than freeze. + expect(pause.isPaused, isFalse); + }); + + test('stays paused for tiny jitter within the touch slop', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, withinSlop); + + expect(pause.isPaused, isTrue); + }); + + test('stays resumed for the rest of the gesture even if the finger drifts ' + 'back within slop', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, beyondSlop); + pause.pointerMove(1, withinSlop); + + // Once a gesture is recognized as a scroll it should not flip back to a + // hold mid-drag. + expect(pause.isPaused, isFalse); + }); + + test('stays paused until every stationary pointer lifts (multi-touch)', () { + pause.pointerDown(1, Offset.zero); + pause.pointerDown(2, Offset.zero); + pause.pointerUp(1); + + expect(pause.isPaused, isTrue); + + pause.pointerUp(2); + expect(pause.isPaused, isFalse); + }); + + test('a stray pointer-up never leaves the gate stuck', () { + pause.pointerUp(99); + + expect(pause.isPaused, isFalse); + + pause.pointerDown(1, Offset.zero); + expect(pause.isPaused, isTrue); + }); + + test('reset clears all active pointers', () { + pause.pointerDown(1, Offset.zero); + pause.pointerDown(2, Offset.zero); + + pause.reset(); + + expect(pause.isPaused, isFalse); + }); +}