Skip to content

Commit 2a88876

Browse files
Fix Message Positioning in Reaction Context Menu (#712)
* patch: maintain same message bubble location on both chatscreen and context screen * added change to changelog * style: improve code formatting and remove whitespace in chat widgets * Update message top offset comment Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor: simplify message position calculation by removing unused vertical adjustment * fix: improve reaction dialog positioning by calculating from message top instead of center * refactor: extract reaction dialog positioning logic into separate widget class * style: improve code formatting in reactions dialog widget positioning logic --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 8b209bb commit 2a88876

File tree

5 files changed

+160
-19
lines changed

5 files changed

+160
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Fixed 2 users group creation when DM already exists
1717
- Fixed issue where keyboard covers part of the "introduce yourself" textfield.
1818
- Improved close reply tap area/response.
19-
2019
- Improved haptic feedback for chat context menus.
2120
- Pinned the header in auth flow screens when keyboard is open for easier back navigation.
2221
- Fixed broken profile image upload [#701](https://github.com/parres-hq/whitenoise_flutter/pull/701)
2322
- Fixed double rendering issue for some messages [#654](https://github.com/parres-hq/whitenoise_flutter/pull/654)
23+
- Fixed message bubble jumping to another location on context screen.
2424
### Security
2525

2626

lib/ui/chat/chat_screen.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,11 +355,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
355355
groupId: widget.groupId,
356356
),
357357
onLongPress:
358-
() => ChatDialogService.showReactionDialog(
358+
(position) => ChatDialogService.showReactionDialog(
359359
context: context,
360360
ref: ref,
361361
message: message,
362362
messageIndex: messageIndex,
363+
messagePosition: position,
363364
),
364365
child: Hero(
365366
tag: message.id,

lib/ui/chat/services/chat_dialog_service.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ class ChatDialogService {
8787
required WidgetRef ref,
8888
required MessageModel message,
8989
required int messageIndex,
90+
Offset? messagePosition,
9091
}) {
9192
final chatNotifier = ref.read(chatProvider.notifier);
9293

@@ -138,6 +139,7 @@ class ChatDialogService {
138139
}
139140
},
140141
widgetAlignment: message.isMe ? Alignment.centerRight : Alignment.centerLeft,
142+
messagePosition: messagePosition,
141143
);
142144
},
143145
),

lib/ui/chat/widgets/reaction/reactions_dialog_widget.dart

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class ReactionsDialogWidget extends StatefulWidget {
2121
this.reactions = DefaultData.reactions,
2222
this.widgetAlignment = Alignment.centerRight,
2323
this.menuItemsWidth = 0.50,
24+
this.messagePosition,
2425
});
2526

2627
// Id for the hero widget
@@ -47,6 +48,9 @@ class ReactionsDialogWidget extends StatefulWidget {
4748
// The width of the menu items
4849
final double menuItemsWidth;
4950

51+
// The position of the message on screen (optional)
52+
final Offset? messagePosition;
53+
5054
@override
5155
State<ReactionsDialogWidget> createState() => _ReactionsDialogWidgetState();
5256
}
@@ -69,19 +73,17 @@ class _ReactionsDialogWidgetState extends State<ReactionsDialogWidget> {
6973
child: BackdropFilter(
7074
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
7175
child: SafeArea(
72-
child: Column(
73-
children: [
74-
const Spacer(),
75-
buildReactions(context),
76-
Gap(16.h),
77-
Padding(
78-
padding: EdgeInsets.symmetric(horizontal: 16.w),
79-
child: buildMessage(),
80-
),
81-
Gap(16.h),
82-
buildMenuItems(context),
83-
Gap(32.h),
84-
],
76+
child: LayoutBuilder(
77+
builder: (context, constraints) {
78+
return _PositionedContent(
79+
messagePosition: widget.messagePosition,
80+
menuItemsCount: widget.menuItems.length,
81+
buildReactions: () => buildReactions(context),
82+
buildMessage: buildMessage,
83+
buildMenuItems: () => buildMenuItems(context),
84+
constraints: constraints,
85+
);
86+
},
8587
),
8688
),
8789
),
@@ -94,7 +96,7 @@ class _ReactionsDialogWidgetState extends State<ReactionsDialogWidget> {
9496
alignment: widget.widgetAlignment,
9597
child: Container(
9698
width: MediaQuery.of(context).size.width * widget.menuItemsWidth,
97-
margin: EdgeInsets.symmetric(horizontal: 48.w),
99+
margin: EdgeInsets.symmetric(horizontal: 18.w),
98100
decoration: BoxDecoration(
99101
color: context.colors.primaryForeground,
100102
),
@@ -164,7 +166,7 @@ class _ReactionsDialogWidgetState extends State<ReactionsDialogWidget> {
164166
return Align(
165167
alignment: widget.widgetAlignment,
166168
child: Container(
167-
margin: EdgeInsets.symmetric(horizontal: 16.w),
169+
margin: EdgeInsets.symmetric(horizontal: 8.w),
168170
child: widget.messageWidget,
169171
),
170172
);
@@ -240,3 +242,124 @@ class _ReactionsDialogWidgetState extends State<ReactionsDialogWidget> {
240242
);
241243
}
242244
}
245+
246+
class _PositionedContent extends StatelessWidget {
247+
const _PositionedContent({
248+
required this.messagePosition,
249+
required this.menuItemsCount,
250+
required this.buildReactions,
251+
required this.buildMessage,
252+
required this.buildMenuItems,
253+
required this.constraints,
254+
});
255+
256+
final Offset? messagePosition;
257+
final int menuItemsCount;
258+
final Widget Function() buildReactions;
259+
final Widget Function() buildMessage;
260+
final Widget Function() buildMenuItems;
261+
final BoxConstraints constraints;
262+
263+
@override
264+
Widget build(BuildContext context) {
265+
final reactionBarHeight = 56.h; // Approximate height of reaction bar
266+
final menuItemsHeight = (menuItemsCount * 48.h) + 32.h; // Menu items + bottom gap
267+
final gapBetweenReactionsAndMessage = 16.h;
268+
final gapBetweenMessageAndMenu = 16.h;
269+
270+
final heightBelowMessage = gapBetweenMessageAndMenu + menuItemsHeight;
271+
272+
if (messagePosition != null) {
273+
final topSpacing = _calculateTopSpacing(
274+
context: context,
275+
reactionBarHeight: reactionBarHeight,
276+
heightBelowMessage: heightBelowMessage,
277+
gapBetweenReactionsAndMessage: gapBetweenReactionsAndMessage,
278+
);
279+
280+
return _buildPositionedLayout(
281+
topSpacing: topSpacing,
282+
gapBetweenReactionsAndMessage: gapBetweenReactionsAndMessage,
283+
gapBetweenMessageAndMenu: gapBetweenMessageAndMenu,
284+
);
285+
}
286+
287+
return _buildCenteredLayout();
288+
}
289+
290+
double _calculateTopSpacing({
291+
required BuildContext context,
292+
required double reactionBarHeight,
293+
required double heightBelowMessage,
294+
required double gapBetweenReactionsAndMessage,
295+
}) {
296+
// Get the safe area insets to adjust for status bar, notch, etc.
297+
final mediaQuery = MediaQuery.of(context);
298+
final topInset = mediaQuery.padding.top;
299+
300+
// The position passed is the top of the message widget in global coordinates
301+
// Adjust by subtracting the top safe area inset because dialog is inside SafeArea
302+
final messageTopPosition = messagePosition!.dy - topInset;
303+
final screenHeight = constraints.maxHeight;
304+
305+
// Calculate where the reactions bar should be positioned (above the message)
306+
// This offset accounts for typical message padding and visual spacing
307+
final reactionBarOffset = 40.h;
308+
// Additional upward adjustment for better visual alignment
309+
final visualAlignmentOffset = 16.h;
310+
final messageTopY = messageTopPosition - reactionBarOffset - visualAlignmentOffset;
311+
312+
final spaceBelow = screenHeight - messageTopPosition - 40.h;
313+
314+
// Check if we need to move the message up to fit the menu
315+
final adjustedMessageTopY =
316+
spaceBelow < heightBelowMessage
317+
? (messageTopY - (heightBelowMessage - spaceBelow)).clamp(
318+
reactionBarHeight + gapBetweenReactionsAndMessage + 20.h, // Minimum top position
319+
messageTopY, // Don't move down, only up
320+
)
321+
: messageTopY;
322+
323+
// Position with reactions above the message position
324+
return (adjustedMessageTopY - reactionBarHeight - gapBetweenReactionsAndMessage).clamp(
325+
0.0,
326+
screenHeight,
327+
);
328+
}
329+
330+
Widget _buildPositionedLayout({
331+
required double topSpacing,
332+
required double gapBetweenReactionsAndMessage,
333+
required double gapBetweenMessageAndMenu,
334+
}) {
335+
return Column(
336+
crossAxisAlignment: CrossAxisAlignment.stretch,
337+
children: [
338+
Gap(topSpacing),
339+
buildReactions(),
340+
Gap(gapBetweenReactionsAndMessage),
341+
buildMessage(),
342+
Gap(gapBetweenMessageAndMenu),
343+
buildMenuItems(),
344+
const Spacer(),
345+
],
346+
);
347+
}
348+
349+
Widget _buildCenteredLayout() {
350+
return Column(
351+
children: [
352+
const Spacer(),
353+
buildReactions(),
354+
Gap(16.h),
355+
Padding(
356+
padding: EdgeInsets.symmetric(horizontal: 16.w),
357+
child: buildMessage(),
358+
),
359+
Gap(16.h),
360+
buildMenuItems(),
361+
Gap(32.h),
362+
],
363+
);
364+
}
365+
}

lib/ui/chat/widgets/swipe_to_reply_widget.dart

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:whitenoise/ui/core/ui/wn_image.dart';
1111
class SwipeToReplyWidget extends StatefulWidget {
1212
final MessageModel message;
1313
final VoidCallback onReply;
14-
final VoidCallback onLongPress;
14+
final Function(Offset) onLongPress;
1515
final Widget child;
1616

1717
const SwipeToReplyWidget({
@@ -36,6 +36,7 @@ class _SwipeToReplyWidgetState extends State<SwipeToReplyWidget> {
3636
bool _canUndo = false;
3737
Timer? _longPressTimer;
3838
Timer? _longPressHapticTimer;
39+
Offset? _tapPosition;
3940

4041
void _handleDragStart(DragStartDetails details) {
4142
_longPressTimer?.cancel();
@@ -88,13 +89,27 @@ class _SwipeToReplyWidgetState extends State<SwipeToReplyWidget> {
8889
_longPressTimer?.cancel();
8990
_longPressHapticTimer?.cancel();
9091

92+
// Store the tap position - this is the global position on screen
93+
_tapPosition = details.globalPosition;
94+
9195
_longPressHapticTimer = Timer(const Duration(milliseconds: 100), () {
9296
HapticFeedback.mediumImpact();
9397
});
9498

9599
_longPressTimer = Timer(const Duration(milliseconds: 350), () {
96100
_longPressHapticTimer?.cancel();
97-
widget.onLongPress();
101+
102+
// Get the widget's position on screen using RenderBox
103+
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
104+
if (renderBox != null) {
105+
// Get position relative to the entire screen (global coordinates)
106+
final position = renderBox.localToGlobal(Offset.zero);
107+
108+
widget.onLongPress(position);
109+
} else {
110+
// Fallback to tap position if RenderBox is not available
111+
widget.onLongPress(_tapPosition ?? Offset.zero);
112+
}
98113
});
99114
}
100115

0 commit comments

Comments
 (0)