Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
759b401
message [nfc]: Move hasSelfVote to MessageStore, rename to selfHasVoted
sm-sayedi May 21, 2026
dba3284
model: Add Reactions.selfVotes method
sm-sayedi Jun 19, 2026
9c20ffd
message: Simplify selfHasVoted to use Reactions.selfVotes
sm-sayedi Jun 19, 2026
926ea0c
emoji_reaction: Highlight message reaction emojis in reaction emoji p…
sm-sayedi Jun 19, 2026
2f33d8b
emoji_reaction: Make emoji picker remove a reaction when already reacted
sm-sayedi May 28, 2026
b8f407c
actions [nfc]: Simplify addOrRemoveReaction, rename to toggleReaction
sm-sayedi May 21, 2026
b4987d4
deps: Add leak_tracker_flutter_testing as a direct dev dependency
sm-sayedi Jul 15, 2026
53556ab
double-tap: Introduce DoubleTapRecognizer
sm-sayedi Jul 13, 2026
1c0069b
double-tap test: Adjust a test group to exercise onTap, not onTapDown
sm-sayedi Jul 16, 2026
70a2e14
double-tap: Make DoubleTapRecognizer not participate in gesture arena
sm-sayedi Jul 13, 2026
25de342
double-tap: Remove a few properties we don't plan to use
sm-sayedi Jul 13, 2026
41a4cde
double-tap [nfc]: Tighten up the code
sm-sayedi Jul 15, 2026
d69eb18
double-tap test: Tighten up the code
sm-sayedi Jul 16, 2026
217a233
double-tap [nfc]: Move DoubleTapRecognizer to the top of the file
sm-sayedi Jul 15, 2026
e18c3b3
double-tap: Cancel `_TapTracker`'s internal timer when appropriate
sm-sayedi Jul 16, 2026
946dd87
double-tap: Introduce DoubleTapListener
sm-sayedi Jul 14, 2026
0d0581e
msglist: Double-tap to add/remove +1 reaction
sm-sayedi May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/THIRDPARTY
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: Zulip Flutter
Upstream-Contact: Zulip development community <https://chat.zulip.org>
Source: https://github.com/zulip/zulip-flutter
Comment:
Unless otherwise noted, the Zulip Flutter app is distributed under the
Apache License, Version 2.0; see the LICENSE file. The software includes
some works released by third parties under other free and open source
licenses. Those works are redistributed under the license terms under
which the works were received.
.
(Licenses for fonts and other assets in our asset bundle appear in
LICENSE files alongside the assets themselves, and are shown to users
in the app's licenses page; see lib/licenses.dart.)

Files: *
Copyright: Kandra Labs, Inc., and contributors
License: Apache-2.0

Files: lib/widgets/double_tap_recognizer.dart
test/widgets/double_tap_recognizer_test.dart
test/widgets/gesture_tester.dart
Copyright: 2014 The Flutter Authors
License: BSD-3-clause
Comment:
Copied and adapted from the Flutter framework, at commit ab7eb7aff:
.
https://github.com/flutter/flutter/blob/ab7eb7aff/packages/flutter/lib/src/gestures/multitap.dart#L49-L382
https://github.com/flutter/flutter/blob/ab7eb7aff/packages/flutter/test/gestures/double_tap_test.dart
https://github.com/flutter/flutter/blob/ab7eb7aff/packages/flutter/test/gestures/gesture_tester.dart
.
See the header comments in those files for details of the adaptations.

License: Apache-2.0
The complete text of the Apache License, Version 2.0 can be found in
the LICENSE file in the root of this source tree, or at
https://www.apache.org/licenses/LICENSE-2.0 .

License: BSD-3-clause
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
.
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
13 changes: 13 additions & 0 deletions lib/api/model/reaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ class Reactions {
}
_total--;
}

/// The reactions the self-user has voted for, as (type, code) pairs.
///
/// This walks [aggregated] on each call.
/// Callers checking many emojis against the result
/// (e.g. each entry in the emoji picker)
/// should call this once and reuse the returned set,
/// rather than calling it per emoji.
Set<({ReactionType type, String code})> selfVotes(int selfUserId) {
return Set.of(aggregated
.where((reactionWithVotes) => reactionWithVotes.userIds.contains(selfUserId))
.map((e) => (code: e.emojiCode, type: e.reactionType)));
}
}

/// A data structure identifying a reaction and who has voted for it.
Expand Down
13 changes: 13 additions & 0 deletions lib/model/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import '../api/route/messages.dart';
import '../log.dart';
import 'binding.dart';
import 'channel.dart';
import 'emoji.dart';
import 'message_list.dart';
import 'realm.dart';
import 'store.dart';
Expand Down Expand Up @@ -91,6 +92,18 @@ mixin MessageStore on ChannelStore {
/// per [getEditMessageErrorStatus].
({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId);

/// Whether the self-user has voted the message with the given emoji.
bool selfHasVoted(int messageId, {required EmojiCandidate withEmoji}) {
final message = messages[messageId];
if (message == null) {
assert(false); // TODO(log)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

assert(false) should be unreachable, and be easy to verify as unreachable by reading code.

This one turns out to be reachable, which means it can crash debug builds:

In a debug build from this PR:

  1. Long-press any message you can delete, to open the message action sheet
  2. Star the message so that step 3 will cause a rebuild (otherwise do something after step 3 that notifies MessageStore's listeners, like starring a different message)
  3. Delete the message from another client
  4. See error:
Image

In another comment I suggested providing a self-votes set on Reactions itself, so with that, I think the fix here is for ReactionButtons to get that set from the message it's passed. So in ReactionButtons.build:

final selfVotes = message.reactions?.selfVotes(store.selfUserId) ?? {};

and pass that set to _buildButton.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the details.

This symptom doesn't seem to reproduce at the commit where selfHasVoted was first introduced:

message [nfc]: Move hasSelfVote to MessageStore, rename to selfHasVoted

But rather at a later commit where it uses the store from a different context:

actions [nfc]: Simplify addOrRemoveReaction, rename to toggleReaction

So PerAccountStoreWidget.of(pageContext).selfHasVoted in the first commit
vs.
PerAccountStoreWidget.of(context).selfHasVoted in the later commit.

That means a change in the store from pageContext doesn't call the current context's build method in the first commit. Changed the later commit to use pageContext as before.

But besides that, it turns out assert(false) is still reachable (from the second commit mentioned):

  1. Long-press any message you can delete to open the message action sheet.
  2. Tap the "more >" button in ReactionButtons to open the emoji picker.
  3. Delete the message from another client.
  4. Tap an emoji entry in the emoji picker to add a reaction.
  5. See the assert output in the console.

What about removing the assert? Now that there can be at least one path for the assert to be triggered.

return false;
}

return message.reactions?.selfVotes(selfUserId)
.contains((code: withEmoji.emojiCode, type: withEmoji.emojiType)) ?? false;
}

/// Whether the user has permission to delete a message, as of [atDate].
///
/// For a value of [atDate], use [ZulipBinding.instance.utcNow].
Expand Down
51 changes: 12 additions & 39 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1355,23 +1355,12 @@ class ReactionButtons extends StatelessWidget {
/// triggered from.
final BuildContext pageContext;

void _handleTapReaction({
required EmojiCandidate emoji,
required bool isSelfVoted,
}) {
void _handleTapReaction(EmojiCandidate emoji) {
// Dismiss the enclosing action sheet immediately,
// for swift UI feedback that the user's selection was received.
Navigator.pop(pageContext);

final zulipLocalizations = ZulipLocalizations.of(pageContext);
ZulipAction.addOrRemoveReaction(
context: pageContext,
doRemoveReaction: isSelfVoted,
messageId: message.id,
emoji: emoji,
errorDialogTitle: isSelfVoted
? zulipLocalizations.errorReactionRemovingFailedTitle
: zulipLocalizations.errorReactionAddingFailedTitle);
ZulipAction.toggleReaction(pageContext, messageId: message.id, emoji: emoji);
}

void _handleTapMore() async {
Expand All @@ -1381,26 +1370,22 @@ class ReactionButtons extends StatelessWidget {
// Dismiss current action sheet before opening emoji picker sheet.
Navigator.of(pageContext).pop();

final emoji = await showEmojiPickerSheet(pageContext: pageContext);
final emoji = await showEmojiPickerSheet(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

emoji-reaction: Highlight message reaction emojis in reaction emoji picker

To match the behavior on the web.

In the next commits where we remove a reaction when an already-voted
emoji is tapped from the emoji picker, this change will hightlight that
emoji so the user knows the consequence of their tap (adding a reaction
by tapping an unhighlighted emoji and removing a reaction by tapping
a hightlighted emoji).

Commit-message nits:

  • git lsp --grep='emoji-reaction:' shows that we haven't used emoji-reaction: as a summary-line prefix before. Could use emoji_reaction:, maybe, since that's the file where showEmojiPickerSheet is defined.
  • spelling of "highlighted"

pageContext: pageContext,
selectedEmojis: message.reactions?.selfVotes(PerAccountStoreWidget.of(pageContext).selfUserId) ?? {});
if (emoji == null || !pageContext.mounted) return;
unawaited(ZulipAction.addOrRemoveReaction(
context: pageContext,
doRemoveReaction: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

actions [nfc]: Simplify addOrRemoveReaction
…

Previously this was passing doRemoveReaction: false unconditionally, and now it will toggle between add/remove depending on whether that reaction exists, right?
Since this is not an NFC change, it should go in its own commit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct, thanks for flagging.

messageId: message.id,
emoji: emoji,
errorDialogTitle:
ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle));
unawaited(ZulipAction.toggleReaction(pageContext,
messageId: message.id, emoji: emoji));
}

Widget _buildButton({
required BuildContext context,
Widget _buildButton(BuildContext context, {
required EmojiCandidate emoji,
required bool isSelfVoted,
required bool isFirst,
}) {
final store = PerAccountStoreWidget.of(pageContext);
final designVariables = DesignVariables.of(context);
return Flexible(child: InkWell(
onTap: () => _handleTapReaction(emoji: emoji, isSelfVoted: isSelfVoted),
onTap: () => _handleTapReaction(emoji),
splashFactory: NoSplash.splashFactory,
borderRadius: isFirst
? const BorderRadiusDirectional.only(topStart: Radius.circular(7))
Expand All @@ -1414,7 +1399,7 @@ class ReactionButtons extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5),
alignment: Alignment.center,
color: isSelfVoted
color: store.selfHasVoted(message.id, withEmoji: emoji)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: null,
child: UnicodeEmojiWidget(
Expand All @@ -1439,25 +1424,13 @@ class ReactionButtons extends StatelessWidget {
final zulipLocalizations = ZulipLocalizations.of(context);
final designVariables = DesignVariables.of(context);

bool hasSelfVote(EmojiCandidate emoji) {
return message.reactions?.aggregated.any((reactionWithVotes) {
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& reactionWithVotes.emojiCode == emoji.emojiCode
&& reactionWithVotes.userIds.contains(store.selfUserId);
}) ?? false;
}

return Container(
decoration: BoxDecoration(
color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
child: Row(children: [
Flexible(child: Row(spacing: 1, children: List.unmodifiable(
popularEmojiCandidates.mapIndexed((index, emoji) =>
_buildButton(
context: context,
emoji: emoji,
isSelfVoted: hasSelfVote(emoji),
isFirst: index == 0))))),
_buildButton(context, emoji: emoji, isFirst: index == 0))))),
InkWell(
onTap: _handleTapMore,
splashFactory: NoSplash.splashFactory,
Expand Down
24 changes: 14 additions & 10 deletions lib/widgets/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,20 +276,21 @@ abstract final class ZulipAction {
return fetchedMessage?.content;
}

/// Adds or removes a reaction on the message corresponding to
/// the [messageId], showing an error dialog on failure.
/// Returns a Future resolving to true if operation succeeds.
static Future<void> addOrRemoveReaction({
required BuildContext context,
required bool doRemoveReaction,
/// Toggles the self-vote for the given reaction for [messageId],
/// showing an error dialog on failure.
///
/// Returns a Future that completes successfully whether the operation
/// succeeds or fails.
static Future<void> toggleReaction(BuildContext context, {
required int messageId,
required EmojiCandidate emoji,
required String errorDialogTitle,
}) async {
final store = PerAccountStoreWidget.of(context);
String? errorMessage;
final zulipLocalizations = ZulipLocalizations.of(context);

final isSelfVoted = store.selfHasVoted(messageId, withEmoji: emoji);
try {
await (doRemoveReaction ? removeReaction : addReaction).call(
await (isSelfVoted ? removeReaction : addReaction).call(
store.connection,
messageId: messageId,
reactionType: emoji.emojiType,
Expand All @@ -299,6 +300,7 @@ abstract final class ZulipAction {
} catch (e) {
if (!context.mounted) return;

String? errorMessage;
switch (e) {
case ZulipApiException():
errorMessage = e.message;
Expand All @@ -309,7 +311,9 @@ abstract final class ZulipAction {
}

showErrorDialog(context: context,
title: errorDialogTitle,
title: isSelfVoted
? zulipLocalizations.errorReactionRemovingFailedTitle
: zulipLocalizations.errorReactionAddingFailedTitle,
message: errorMessage);
return;
}
Expand Down
95 changes: 95 additions & 0 deletions lib/widgets/double_tap_listener.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'dart:ui';

import 'package:flutter/widgets.dart';

import 'double_tap_recognizer.dart';

/// A widget that detects double-tap gestures without participating in the
/// gesture arena.
///
/// This can be useful when adding an `onDoubleTap` handler to a
/// [GestureDetector] would be undesirable because it delays `onTap` handlers by
/// up to [kDoubleTapTimeout] to distinguish between single and double taps.
///
/// The tradeoff is that a double tap may trigger both this widget's callback
/// and another gesture handler in the tree that responds to one or both taps.
///
/// See also:
/// * [DoubleTapRecognizer], which this widget uses internally to recognize
/// double taps without participating in the gesture arena.
//
// TODO(upstream): Delete this class if Flutter gains a way to detect
// double taps without delaying single taps:
// https://github.com/flutter/flutter/issues/106170
// https://github.com/flutter/flutter/issues/110300
class DoubleTapListener extends StatefulWidget {
const DoubleTapListener({
super.key,
this.behavior = .translucent,
this.supportedDevices,
this.onDoubleTap,
this.child,
});

/// How this listener should behave during hit testing when deciding
/// how the hit test propagates to children and whether to consider targets
/// behind this one.
///
/// Defaults to [HitTestBehavior.translucent].
///
/// See [HitTestBehavior] for the allowed values and their meanings.
final HitTestBehavior behavior;

/// The kind of devices that are allowed to be recognized.
///
/// If set to null, events from all device types will be recognized. Defaults to null.
final Set<PointerDeviceKind>? supportedDevices;

/// Called when the user performs a double tap.
///
/// If null, this widget does not recognize double taps.
final VoidCallback? onDoubleTap;

/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget? child;

@override
State<DoubleTapListener> createState() => _DoubleTapListenerState();
}

class _DoubleTapListenerState extends State<DoubleTapListener> {
late final DoubleTapRecognizer _recognizer;

@override
void initState() {
super.initState();
_recognizer = DoubleTapRecognizer()
..supportedDevices = widget.supportedDevices
..onDoubleTap = widget.onDoubleTap;
}

@override
void didUpdateWidget(covariant DoubleTapListener oldWidget) {
super.didUpdateWidget(oldWidget);
_recognizer
..supportedDevices = widget.supportedDevices
..onDoubleTap = widget.onDoubleTap;
}

@override
void dispose() {
_recognizer.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _recognizer.addPointer,
behavior: widget.behavior,
child: widget.child,
);
}
}
Loading
Loading