fix(chat): keep long-press from missing in fast-moving chat

Freeze the message list on a stationary touch-and-hold (pointer-counted,
touch-slop aware) so an incoming message can't shift the row out from
under a long-press, while still allowing scroll to cancel the freeze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Francis Lavoie 2026-06-28 09:51:23 -04:00
parent ef1b1ded5d
commit 4fc4f90087
No known key found for this signature in database
5 changed files with 281 additions and 74 deletions

View file

@ -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,
),

View file

@ -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 = <int, Offset>{};
/// 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 = <int>{};
/// 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();
}
}

View file

@ -11,6 +11,7 @@ import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.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/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
@ -97,11 +98,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;
@ -254,6 +255,20 @@ abstract class ChatStoreBase with Store {
@readonly
var _autoScroll = true;
/// 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 = '';
@ -363,16 +378,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<String> words) {
_normalizedMutedWords = words
.map((word) => word.trim().toLowerCase())
.where((word) => word.isNotEmpty)
.toList();
},
fireImmediately: true,
),
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
_normalizedMutedWords = words
.map((word) => word.trim().toLowerCase())
.where((word) => word.isNotEmpty)
.toList();
}, fireImmediately: true),
);
reactions.add(
@ -604,8 +615,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;
}
@ -1159,7 +1169,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();
@ -1313,7 +1327,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!);
@ -1467,6 +1482,8 @@ abstract class ChatStoreBase with Store {
void dispose() {
_shouldDisconnect = true;
_interactionPause.reset();
_clearPendingDelayedCallbacks();
_messageBufferTimer?.cancel();

View file

@ -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<MergedMessage>? _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 = <String>{};
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();
}

View file

@ -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);
});
}