Skip to content

Swipe to reply#160

Merged
wel97459 merged 18 commits into
zjs81:mainfrom
ChaoticLeah:enhancement/swipe-reply
Feb 22, 2026
Merged

Swipe to reply#160
wel97459 merged 18 commits into
zjs81:mainfrom
ChaoticLeah:enhancement/swipe-reply

Conversation

@ChaoticLeah

@ChaoticLeah ChaoticLeah commented Feb 11, 2026

Copy link
Copy Markdown
Contributor

This PR allows you to swipe on a message to reply to it.

This has been tested on an android phone.

Closes #154

Copilot AI review requested due to automatic review settings February 11, 2026 21:03
@ChaoticLeah

Copy link
Copy Markdown
Contributor Author

Related issue: #154

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “swipe to reply” interaction to the channel chat UI by tracking horizontal swipe gestures on individual message bubbles and showing a reply hint as feedback.

Changes:

  • Introduces per-message swipe tracking state (_swipeStartPosition, _swipeTrackingMessageId, _swipeOffset) and swipe handling helpers.
  • Wraps each message bubble with pointer listeners to translate the bubble during swipe and trigger reply on threshold.
  • Adds a reply hint UI displayed behind the message while swiping.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +131 to +143
void _handleSwipeEnd(
Offset position,
double threshold,
ChannelMessage message,
) {
if (_swipeStartPosition != null) {
final dx = position.dx - _swipeStartPosition!.dx;
if (dx.abs() >= threshold) {
_setReplyingTo(message);
HapticFeedback.selectionClick();
}
}
_resetSwipe();

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_handleSwipeEnd doesn’t verify that the swipe being ended still corresponds to the message being handled (it doesn’t check _swipeTrackingMessageId and doesn’t track the pointer id). With multi-touch (or overlapping gestures), a second pointer can overwrite _swipeStartPosition/_swipeTrackingMessageId, and the first pointer’s onPointerUp can incorrectly trigger reply on the wrong message. Track PointerDownEvent.pointer and validate both pointer id and message id in update/end before applying the threshold / replying.

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated

@446564 446564 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the robot makes some good points, just potential issues to avoid.

But otherwise this looks great and tested and working on my end.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 12, 2026 16:30
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/screens/channel_chat_screen.dart Outdated

final double clamped = dx.clamp(-maxOffset, maxOffset).toDouble();
final adjusted = _applySwipeResistance(clamped, maxOffset);
if (adjusted != _swipeOffset) {

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_handleSwipeUpdate calls setState on pointer-move events, which will rebuild the whole ChannelChatScreen (including the ListView) at gesture sampling rates and can cause jank on long chats. Consider moving the swipe offset into a per-message widget/state (e.g., a dedicated StatefulWidget for a bubble with its own setState/ValueNotifier) so only the swiped row repaints, or otherwise avoid screen-level rebuilds for pointer updates.

Suggested change
if (adjusted != _swipeOffset) {
// Avoid rebuilding the whole screen for tiny, imperceptible changes in offset.
// This throttles setState calls during pointer-move events to reduce jank.
if ((adjusted - _swipeOffset).abs() >= 1.0) {

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +147 to +149
setState(() => _swipeOffset = 0);
_swipeStartPosition = null;
_swipeTrackingMessageId = null;

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resetSwipe always calls setState(() => _swipeOffset = 0) even when no swipe occurred (offset already 0). Since onPointerUp runs for every tap/scroll interaction on a message, this forces an extra rebuild for normal taps and scrolls. Guard the setState (only when _swipeOffset != 0 / swipe was active) and update _swipeStartPosition/_swipeTrackingMessageId inside the same setState to keep state mutations consistent.

Suggested change
setState(() => _swipeOffset = 0);
_swipeStartPosition = null;
_swipeTrackingMessageId = null;
// Avoid unnecessary rebuilds when there is no active swipe state.
if (_swipeOffset == 0 &&
_swipeStartPosition == null &&
_swipeTrackingMessageId == null) {
return;
}
setState(() {
_swipeOffset = 0;
_swipeStartPosition = null;
_swipeTrackingMessageId = null;
});

Copilot uses AI. Check for mistakes.
@446564

446564 commented Feb 12, 2026

Copy link
Copy Markdown
Collaborator

I've also noticed that you can't cancel the reply, if you start to swipe and then move back so the highlight + reply text goes away it still replies to the message.

Also it is very easy to trigger, so the same thing could solve both issues. If the event only fires if the state has changed to highlight with the text and then released, otherwise it should not fire.

446564
446564 previously requested changes Feb 15, 2026

@446564 446564 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#160 (comment)

Please change from draft when ready

@446564 446564 marked this pull request as draft February 15, 2026 16:54
@wel97459 wel97459 marked this pull request as ready for review February 21, 2026 23:43
Copilot AI review requested due to automatic review settings February 21, 2026 23:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +157 to +158
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the dead zone calculation on line 158, the multiplier 0.08 seems arbitrary and could benefit from a named constant or comment explaining its purpose. The magic number makes it difficult to understand the intended sensitivity behavior in the dead zone.

Suggested change
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);
// Scales movement inside the dead zone so feedback starts gently instead of linearly.
const deadZoneSensitivityScale = 0.08;
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * deadZoneSensitivityScale);

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +550 to +554
AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: Matrix4.translationValues(swipeOffset, 0, 0),
curve: Curves.easeOut,
child: messageBody,

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AnimatedContainer animates the transformation when swipeOffset changes, but during an active drag gesture (when the user is swiping), the animation duration of 150ms creates a lag between the finger position and the visual feedback. This can make the swipe feel unresponsive or sluggish.

Consider disabling the animation during active drag by checking if _swipeTrackingMessageId is set, and only animating when snapping back to the original position after the gesture ends.

Copilot uses AI. Check for mistakes.
Comment on lines +562 to +598
Widget _buildReplySwipeHint({required bool isStart}) {
final colorScheme = Theme.of(context).colorScheme;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.reply, color: colorScheme.primary),
const SizedBox(width: 6),
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
);

return Container(
alignment: isStart ? Alignment.centerLeft : Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: colorScheme.primary.withValues(alpha: 0.08),
child: isStart
? content
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(Icons.reply, color: colorScheme.primary),
],
),

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reply hint content (icon and text) is duplicated in two places - once for left-aligned swipes (lines 564-577) and once for right-aligned swipes (lines 585-598). This duplication makes the code harder to maintain. Consider reusing the same content widget and only changing its position in the Row for the right-aligned case.

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +343 to +344
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 48.0;

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maxSwipeOffset (64.0) and replySwipeThreshold (48.0) are defined as local constants within the _buildMessageBubble method. Since these values are recreated on every build, consider extracting them as class-level constants (e.g., static const) to avoid repeated allocations and improve code organization.

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated

double _applySwipeResistance(double rawOffset, double maxOffset) {
final abs = rawOffset.abs();
if (abs <= 0) return 0;

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The division check if (abs <= 0) on line 154 is redundant since abs is the result of .abs() which is always non-negative. The condition should be if (abs == 0) to properly check for zero, though even this is not strictly necessary since the subsequent calculations would naturally return 0 when abs is 0.

Suggested change
if (abs <= 0) return 0;
if (abs == 0) return 0;

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 22, 2026 00:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channelMessage: true parameter is being passed to ChannelMessagePathScreen, but this parameter doesn't exist in the constructor definition. The constructor only accepts message parameter (see channel_message_path_screen.dart:21). This will cause a compilation error. Remove the channelMessage: true parameter from this call.

Suggested change
ChannelMessagePathScreen(message: message, channelMessage: true),
ChannelMessagePathScreen(message: message),

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 22, 2026 00:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +218 to +225
final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0);
final curved = t < 0.5
? 16 * math.pow(t, 5)
: 1 - math.pow(-2 * t + 2, 5) / 2;
const deadZoneEnd = 0.0144;
return rawOffset.sign *
maxOffset *
(deadZoneEnd + curved * (1 - deadZoneEnd));

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_applySwipeResistance computes curved using math.pow, which returns a num. That makes the final expression a num, but the method return type is double, so this will fail strong-mode type checking. Convert the pow(...) results (and/or the final expression) to double before returning.

Copilot uses AI. Check for mistakes.
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelMessagePathScreen is constructed with a channelMessage: named argument here, but the current ChannelMessagePathScreen constructor only accepts {message}. This will not compile unless the screen is updated to accept that parameter (or the argument is removed).

Suggested change
ChannelMessagePathScreen(message: message, channelMessage: true),
ChannelMessagePathScreen(message: message),

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 22, 2026 00:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +217 to +233
double _applySwipeResistance(double rawOffset, double maxOffset) {
final abs = rawOffset.abs();
if (abs <= 0) return 0;
final norm = (abs / maxOffset).clamp(0.0, 1.0);
const deadZone = 0.18;
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);
}
final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0);
final curved = t < 0.5
? 16 * math.pow(t, 5)
: 1 - math.pow(-2 * t + 2, 5) / 2;
const deadZoneEnd = 0.0144;
return rawOffset.sign *
maxOffset *
(deadZoneEnd + curved * (1 - deadZoneEnd));
}

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_applySwipeResistance uses math.pow(...) which returns num, so curved and the final multiplication become num. Since the method returns double, this is likely a compile-time type error (returning num where double is required). Convert the pow results to double (or otherwise ensure the expression is typed as double) before returning.

Copilot uses AI. Check for mistakes.
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelMessagePathScreen is instantiated with a channelMessage named argument, but the current ChannelMessagePathScreen constructor only takes {super.key, required this.message}. This will not compile unless the other screen is updated accordingly. Either remove this argument or update ChannelMessagePathScreen to accept and use it.

Suggested change
ChannelMessagePathScreen(message: message, channelMessage: true),
ChannelMessagePathScreen(message: message),

Copilot uses AI. Check for mistakes.
Comment thread lib/screens/channel_chat_screen.dart Outdated
Comment on lines +596 to +603
return Listener(
onPointerDown: (event) =>
_handleSwipePointerDown(message.messageId, event),
onPointerMove: (event) =>
_handleSwipePointerMove(message.messageId, event, maxSwipeOffset),
onPointerUp: (event) =>
_handleSwipePointerUp(event.position, replySwipeThreshold, message),
onPointerCancel: (_) => _resetSwipe(),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swipe tracking calls setState from onPointerMove, which will rebuild the entire ChannelChatScreen at pointer-move frequency while swiping a single message. In busy channels this can cause jank. Consider isolating swipe state to the message bubble (e.g., a dedicated stateful widget / ValueNotifier + ValueListenableBuilder) so only that row repaints during the gesture.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 22, 2026 00:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +948 to +949
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelMessagePathScreen constructor (in channel_message_path_screen.dart) only accepts {message}; passing channelMessage: true here will not compile. Either remove the argument or update ChannelMessagePathScreen to declare and use a channelMessage named parameter (and update other call sites accordingly).

Suggested change
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),
builder: (context) => ChannelMessagePathScreen(message: message),

Copilot uses AI. Check for mistakes.
Comment on lines +1156 to +1163
double _applySwipeResistance(double rawOffset, double maxOffset) {
final abs = rawOffset.abs();
if (abs <= 0) return 0;
final norm = (abs / maxOffset).clamp(0.0, 1.0);
const deadZone = 0.18;
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);
}

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_applySwipeResistance returns a double, but return 0; returns an int and will fail strong-mode type checking. Use 0.0 (or rawOffset if you want to preserve sign) to keep the return type consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1180
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onPointerUp calls _handleSwipePointerUp for any pointer, but _handleSwipePointerUp doesn’t verify event.pointer == _swipePointerId. A second finger lifting (or another pointer) can reset the swipe or even incorrectly trigger a reply using a mismatched start position. Gate the onPointerUp handler (and possibly onPointerCancel) by pointer id, similar to _handleSwipePointerMove.

Suggested change
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
onPointerUp: (event) {
if (event.pointer == _swipePointerId) {
_handleSwipePointerUp(event.position);
}
},
onPointerCancel: (event) {
if (event.pointer == _swipePointerId) {
_resetSwipe();
}
},

Copilot uses AI. Check for mistakes.
Positioned.fill(
child: Opacity(
opacity: _swipeOffset.abs() / widget.maxSwipeOffset,
child: widget.hintBuilder(isStart: false),

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hintBuilder takes {isStart} and _buildReplySwipeHint has separate start/end layouts, but _SwipeReplyBubble always calls hintBuilder(isStart: false), making the isStart: true branch dead code. Either wire isStart based on swipe direction / bubble alignment, or simplify the API by removing the unused parameter/branch.

Suggested change
child: widget.hintBuilder(isStart: false),
child: widget.hintBuilder(isStart: _swipeOffset > 0),

Copilot uses AI. Check for mistakes.
@zjs81

zjs81 commented Feb 22, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23fba1d331

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Remove undefined constructor argument

ChannelMessagePathScreen is constructed with channelMessage: true, but the screen constructor only defines ChannelMessagePathScreen({super.key, required this.message}) in lib/screens/channel_message_path_screen.dart (no channelMessage named parameter). This introduces a hard compile error (undefined_named_parameter) and prevents the app from building with this change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is so dumb... it not a problem

Comment on lines +1101 to +1104
const axisLockThreshold = 12.0;
if (!_swipeLockedToHorizontal) {
if (-dx < axisLockThreshold) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate swipe lock on vertical motion

The swipe lock checks only horizontal delta (dx) and ignores vertical displacement, so diagonal drags used to scroll the chat can still lock into reply-swipe mode once dx passes 12 px. On touch devices this can trigger accidental replies during normal list scrolling, because _handleSwipePointerUp later evaluates the horizontal peak against the reply threshold.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this not a problem i have noticed.

@wel97459 wel97459 merged commit 7cb4c5a into zjs81:main Feb 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swipe to reply

5 participants