Skip to content

Commit f754754

Browse files
authored
Fix chat bubble blink and tail-follow scroll (#1999)
* Fix message bubble blink surface * Fix tail-follow new message animation
1 parent e859d96 commit f754754

8 files changed

Lines changed: 256 additions & 74 deletions

File tree

lib/ui/home/chat/chat_jump_trace.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import 'package:flutter/widgets.dart';
33
import '../../../utils/logger.dart';
44

55
const chatJumpTraceEnabled = bool.fromEnvironment('MIXIN_CHAT_JUMP_TRACE');
6+
const chatScrollTraceEnabled =
7+
bool.fromEnvironment('MIXIN_CHAT_SCROLL_TRACE') || chatJumpTraceEnabled;
68

79
void traceChatJump(String message) {
810
if (!chatJumpTraceEnabled) return;
911
i('[chat-jump] $message');
1012
}
1113

14+
void traceChatScroll(String message) {
15+
if (!chatScrollTraceEnabled) return;
16+
i('[chat-scroll] $message');
17+
}
18+
1219
String shortMessageId(String? messageId) {
1320
if (messageId == null || messageId.isEmpty) return '-';
1421
if (messageId.length <= 8) return messageId;

lib/ui/home/chat/chat_scroll_coordinator.dart

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class ChatScrollCoordinator {
102102
int _programmaticBottomScrollDepth = 0;
103103
int _jumpButtonFrozenUpdates = 0;
104104
bool _followTailAfterProgrammaticScroll = false;
105+
double _lastTracedScrollPixels = double.nan;
106+
double _lastTracedScrollMax = double.nan;
107+
double _lastTracedScrollDistanceToBottom = double.nan;
105108

106109
void captureViewportState(
107110
List<MessageItem> messages,
@@ -113,6 +116,12 @@ class ChatScrollCoordinator {
113116
_tailFollowAnchorSnapshot = _tailFollowEligible
114117
? _tailFollowAnchorSnapshotFromViewport()
115118
: null;
119+
traceChatScroll(
120+
'capture tailEligible=$_tailFollowEligible '
121+
'anchor=${shortMessageId(_tailFollowAnchorSnapshot?.messageId)} '
122+
'anchorBottom=${formatDouble(_tailFollowAnchorSnapshot?.bottom)} '
123+
'${_formatScrollState()}',
124+
);
116125
}
117126

118127
void updateMessages(
@@ -237,6 +246,16 @@ class ChatScrollCoordinator {
237246
_scheduleViewportStateUpdate(
238247
updateVisibleDate: notification is ScrollEndNotification,
239248
);
249+
if (notification is ScrollStartNotification ||
250+
notification is ScrollEndNotification ||
251+
notification is UserScrollNotification) {
252+
traceChatScroll(
253+
'notification ${notification.runtimeType} '
254+
'direction=${notification is UserScrollNotification ? notification.direction : '-'} '
255+
'drag=${notification is ScrollStartNotification ? notification.dragDetails != null : '-'} '
256+
'${formatScrollMetrics(notification.metrics)}',
257+
);
258+
}
240259
_unlockPreloadOnUserScrollIntent(notification);
241260
if (notification is! ScrollUpdateNotification) return false;
242261
if (_programmaticScrollDepth > 0) {
@@ -505,6 +524,10 @@ class ChatScrollCoordinator {
505524
}) async {
506525
_clearJumpButtonFreeze();
507526
_programmaticBottomScrollDepth++;
527+
traceChatScroll(
528+
'bottom-jump start animated=$animated '
529+
'direction=$animationDirection ${_formatScrollState()}',
530+
);
508531
try {
509532
await _jumpToClamped(
510533
scrollController.position.maxScrollExtent,
@@ -514,6 +537,7 @@ class ChatScrollCoordinator {
514537
);
515538
} finally {
516539
_programmaticBottomScrollDepth--;
540+
traceChatScroll('bottom-jump done ${_formatScrollState()}');
517541
}
518542
}
519543

@@ -744,10 +768,16 @@ class ChatScrollCoordinator {
744768
'delta=${formatDouble(delta)} start=${formatDouble(start)} '
745769
'${formatScrollMetrics(position)}',
746770
);
747-
scrollController.jumpTo(start);
771+
traceChatScroll(
772+
'tail-follow start anchor=${shortMessageId(snapshot.messageId)} '
773+
'delta=${formatDouble(delta)} start=${formatDouble(start)} '
774+
'${_formatScrollState()}',
775+
);
776+
scrollController.position.correctPixels(start);
748777
}
749778

750779
void _scheduleScrollPositionUpdate() {
780+
_traceScrollPosition();
751781
_scheduleViewportStateUpdate(updateVisibleDate: false);
752782
}
753783

@@ -791,6 +821,13 @@ class ChatScrollCoordinator {
791821
List<MessageItem> messages,
792822
Map<String, GlobalKey> keysByMessageId,
793823
) {
824+
final previousCount = _messages.length;
825+
final previousBottomMessageId = _messages.isEmpty
826+
? null
827+
: _messages.last.messageId;
828+
final nextBottomMessageId = messages.isEmpty
829+
? null
830+
: messages.last.messageId;
794831
final messageWindowChanged = !_hasSameMessageIds(messages);
795832
if (!identical(_messages, messages)) {
796833
_messages = messages;
@@ -801,6 +838,12 @@ class ChatScrollCoordinator {
801838
}
802839
if (messageWindowChanged) {
803840
_freezeJumpButton();
841+
traceChatScroll(
842+
'messages changed count=$previousCount->${messages.length} '
843+
'bottom=${shortMessageId(previousBottomMessageId)}'
844+
'->${shortMessageId(nextBottomMessageId)} '
845+
'${_formatScrollState()}',
846+
);
804847
}
805848
_keysByMessageId = keysByMessageId;
806849
}
@@ -845,6 +888,40 @@ class ChatScrollCoordinator {
845888
return object is RenderBox ? object : null;
846889
}
847890

891+
void _traceScrollPosition() {
892+
if (!chatScrollTraceEnabled || !scrollController.hasClients) return;
893+
final position = scrollController.position;
894+
if (!position.hasContentDimensions) return;
895+
final distanceToBottom = _distanceToBottom();
896+
final pixels = position.pixels;
897+
final max = position.maxScrollExtent;
898+
final shouldTrace =
899+
_lastTracedScrollPixels.isNaN ||
900+
(pixels - _lastTracedScrollPixels).abs() >= 1 ||
901+
(max - _lastTracedScrollMax).abs() >= 1 ||
902+
(distanceToBottom - _lastTracedScrollDistanceToBottom).abs() >= 1;
903+
if (!shouldTrace) return;
904+
_lastTracedScrollPixels = pixels;
905+
_lastTracedScrollMax = max;
906+
_lastTracedScrollDistanceToBottom = distanceToBottom;
907+
traceChatScroll(
908+
'position distanceBottom=${formatDouble(distanceToBottom)} '
909+
'programmatic=$_programmaticScrollDepth '
910+
'bottomProgrammatic=$_programmaticBottomScrollDepth '
911+
'followPending=$_followTailAfterProgrammaticScroll '
912+
'${formatScrollMetrics(position)}',
913+
);
914+
}
915+
916+
String _formatScrollState() {
917+
if (!scrollController.hasClients) return 'no-scroll-client';
918+
return 'distanceBottom=${formatDouble(_distanceToBottom())} '
919+
'programmatic=$_programmaticScrollDepth '
920+
'bottomProgrammatic=$_programmaticBottomScrollDepth '
921+
'messages=${_messages.length} '
922+
'${formatScrollMetrics(scrollController.position)}';
923+
}
924+
848925
void _traceTargetAfterLayout(
849926
String phase,
850927
String messageId,

lib/ui/home/notifier/message_controller.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,9 +615,19 @@ class MessageController extends ValueNotifier<MessageState> {
615615
return;
616616
}
617617

618+
traceChatScroll(
619+
'insert current conv=${shortMessageId(conversationId)} '
620+
'incoming=${list.length} '
621+
'ids=${list.map((e) => shortMessageId(e.messageId)).join(',')} '
622+
'stateLatest=${state.isLatest} '
623+
'stateBottom=${shortMessageId(state.bottomMessage?.messageId)}',
624+
);
618625
final result = _insertOrReplace(conversationId, list);
619626
if (result != null) {
627+
traceChatScroll('insert emit ${_formatWindow(result)}');
620628
_emit(_pretreatment(result));
629+
} else {
630+
traceChatScroll('insert reload-latest requested');
621631
}
622632
}
623633

lib/widgets/message/item/action/action_message.dart

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class ActionMessage extends HookConsumerWidget {
5555
}
5656
}
5757

58-
class ActionMessageButton extends ConsumerWidget {
58+
class ActionMessageButton extends HookConsumerWidget {
5959
const ActionMessageButton({required this.action, super.key});
6060

6161
final ActionData action;
@@ -67,6 +67,68 @@ class ActionMessageButton extends ConsumerWidget {
6767
showNip: false,
6868
nipPadding: false,
6969
);
70+
final highlightEnabled = useMessageHighlightEnabled();
71+
final menuHighlighted = useMessageMenuHighlighted();
72+
Widget button = CustomPaint(
73+
painter: BubblePainter(
74+
color: context.theme.primary,
75+
clipper: bubbleClipper,
76+
),
77+
child: IntrinsicWidth(
78+
child: Stack(
79+
fit: StackFit.passthrough,
80+
children: [
81+
Padding(
82+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
83+
child: Text(
84+
action.label,
85+
textAlign: TextAlign.center,
86+
style: TextStyle(
87+
fontSize: context.messageStyle.secondaryFontSize,
88+
color: colorHex(action.color) ?? Colors.black,
89+
height: 1,
90+
),
91+
),
92+
),
93+
if (action.isSendUserLink)
94+
Align(
95+
alignment: Alignment.topRight,
96+
child: Padding(
97+
padding: const EdgeInsets.all(6),
98+
child: SvgPicture.asset(
99+
Resources.assetsImagesLinkSendSvg,
100+
width: 8,
101+
height: 8,
102+
),
103+
),
104+
)
105+
else if (action.isExternalLink)
106+
Align(
107+
alignment: Alignment.topRight,
108+
child: Padding(
109+
padding: const EdgeInsets.all(6),
110+
child: SvgPicture.asset(
111+
Resources.assetsImagesExternalLinkSvg,
112+
width: 6,
113+
height: 6,
114+
),
115+
),
116+
),
117+
],
118+
),
119+
),
120+
);
121+
if (highlightEnabled || menuHighlighted) {
122+
button = MessageBubbleHighlight(
123+
messageId: context.message.messageId,
124+
enabled: highlightEnabled,
125+
clipper: bubbleClipper,
126+
currentUser: false,
127+
media: true,
128+
menuHighlighted: menuHighlighted,
129+
child: button,
130+
);
131+
}
70132
return InteractiveDecoratedBox.color(
71133
cursor: SystemMouseCursors.click,
72134
onTap: () {
@@ -78,58 +140,7 @@ class ActionMessageButton extends ConsumerWidget {
78140
conversationId: ref.read(currentConversationIdProvider),
79141
);
80142
},
81-
child: CustomPaint(
82-
painter: BubblePainter(
83-
color: context.theme.primary,
84-
clipper: bubbleClipper,
85-
),
86-
child: IntrinsicWidth(
87-
child: Stack(
88-
fit: StackFit.passthrough,
89-
children: [
90-
Padding(
91-
padding: const EdgeInsets.symmetric(
92-
horizontal: 14,
93-
vertical: 8,
94-
),
95-
child: Text(
96-
action.label,
97-
textAlign: TextAlign.center,
98-
style: TextStyle(
99-
fontSize: context.messageStyle.secondaryFontSize,
100-
color: colorHex(action.color) ?? Colors.black,
101-
height: 1,
102-
),
103-
),
104-
),
105-
if (action.isSendUserLink)
106-
Align(
107-
alignment: Alignment.topRight,
108-
child: Padding(
109-
padding: const EdgeInsets.all(6),
110-
child: SvgPicture.asset(
111-
Resources.assetsImagesLinkSendSvg,
112-
width: 8,
113-
height: 8,
114-
),
115-
),
116-
)
117-
else if (action.isExternalLink)
118-
Align(
119-
alignment: Alignment.topRight,
120-
child: Padding(
121-
padding: const EdgeInsets.all(6),
122-
child: SvgPicture.asset(
123-
Resources.assetsImagesExternalLinkSvg,
124-
width: 6,
125-
height: 6,
126-
),
127-
),
128-
),
129-
],
130-
),
131-
),
132-
),
143+
child: button,
133144
);
134145
}
135146
}

lib/widgets/message/item/action_card/actions_card.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ class _Bubble extends HookWidget {
7474
@override
7575
Widget build(BuildContext context) {
7676
final isCurrentUser = useIsCurrentUser();
77+
final highlightEnabled = useMessageHighlightEnabled();
78+
final menuHighlighted = useMessageMenuHighlighted();
7779
final clipper = BubbleClipper(currentUser: isCurrentUser, showNip: true);
7880
final bubbleColor = context.messageBubbleColor(isCurrentUser);
79-
return CustomPaint(
81+
Widget bubble = CustomPaint(
8082
painter: BubblePainter(color: bubbleColor, clipper: clipper),
8183
child: ClipPath(
8284
clipper: clipper,
@@ -86,6 +88,18 @@ class _Bubble extends HookWidget {
8688
),
8789
),
8890
);
91+
if (highlightEnabled || menuHighlighted) {
92+
bubble = MessageBubbleHighlight(
93+
messageId: context.message.messageId,
94+
enabled: highlightEnabled,
95+
clipper: clipper,
96+
currentUser: isCurrentUser,
97+
media: false,
98+
menuHighlighted: menuHighlighted,
99+
child: bubble,
100+
);
101+
}
102+
return bubble;
89103
}
90104
}
91105

lib/widgets/message/message_bubble.dart

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ class MessageBubble extends HookConsumerWidget {
142142
);
143143
}
144144

145-
if (highlightEnabled || menuHighlighted) {
146-
_child = _MessageBubbleHighlight(
145+
final hasHighlightSurface = hasQuoteMessage || showBubble || highlightMedia;
146+
if (hasHighlightSurface && (highlightEnabled || menuHighlighted)) {
147+
_child = MessageBubbleHighlight(
147148
messageId: context.message.messageId,
148149
enabled: highlightEnabled,
149150
clipper: clipper,
@@ -240,15 +241,16 @@ class MessageBubble extends HookConsumerWidget {
240241
}
241242
}
242243

243-
class _MessageBubbleHighlight extends StatefulWidget {
244-
const _MessageBubbleHighlight({
244+
class MessageBubbleHighlight extends StatefulWidget {
245+
const MessageBubbleHighlight({
245246
required this.messageId,
246247
required this.enabled,
247248
required this.clipper,
248249
required this.currentUser,
249250
required this.media,
250251
required this.menuHighlighted,
251252
required this.child,
253+
super.key,
252254
});
253255

254256
final String messageId;
@@ -260,11 +262,10 @@ class _MessageBubbleHighlight extends StatefulWidget {
260262
final Widget child;
261263

262264
@override
263-
State<_MessageBubbleHighlight> createState() =>
264-
_MessageBubbleHighlightState();
265+
State<MessageBubbleHighlight> createState() => _MessageBubbleHighlightState();
265266
}
266267

267-
class _MessageBubbleHighlightState extends State<_MessageBubbleHighlight> {
268+
class _MessageBubbleHighlightState extends State<MessageBubbleHighlight> {
268269
BlinkNotifier? _notifier;
269270
BlinkState _blinkState = const BlinkState();
270271

0 commit comments

Comments
 (0)