Skip to content

msglist: Support viewing who reacted to a message #1700

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 2, 2025

Conversation

chrisbobbe
Copy link
Collaborator

@chrisbobbe chrisbobbe commented Jul 10, 2025

Fixes #740.

Followup:

Notable changes from previous revision:

  • "See who reacted" button omitted in action sheet when no reactions
  • Emojis centered horizontally when few
  • Emojis scroll into view when tapped
  • Reasonable UI with VoiceOver (at least in my testing)
  • Wrote some basic tests

Screenshots

I've included some screenshots where there are

  • many emoji
  • many users

and in those, I set the scroll position such that you can see the shadow effect.

Light Dark
image image
image image
image image
image image

Scroll-on-select animation:

Jul-31-2025 17-27-36

@chrisbobbe chrisbobbe requested a review from gnprice July 10, 2025 00:50
@chrisbobbe chrisbobbe added the integration review Added by maintainers when PR may be ready for integration label Jul 10, 2025
@gnprice
Copy link
Member

gnprice commented Jul 10, 2025

Thanks! I skimmed through this and I'm pretty confident it won't break any existing functionality. I also saw you demonstrate this running on your device, and it seemed to work well. So I plan to include this in today's upcoming release, without yet merging to main.

@sm-sayedi sm-sayedi mentioned this pull request Jul 15, 2025
@chrisbobbe chrisbobbe force-pushed the pr-see-who-reacted branch from 27dd265 to d0f7d15 Compare July 25, 2025 21:44
@chrisbobbe chrisbobbe marked this pull request as ready for review July 25, 2025 21:46
@chrisbobbe
Copy link
Collaborator Author

Thanks! This is now ready for review, and I've updated the issue description with changes since the draft.

@chrisbobbe chrisbobbe force-pushed the pr-see-who-reacted branch from d0f7d15 to 399cd6a Compare July 25, 2025 21:47
@gnprice
Copy link
Member

gnprice commented Jul 25, 2025

Thanks! Would you also post a few screenshots?

@chrisbobbe chrisbobbe force-pushed the pr-see-who-reacted branch from 399cd6a to e653144 Compare July 25, 2025 22:04
@chrisbobbe
Copy link
Collaborator Author

Sure! Done.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks!

Generally this looks good. Here's a full review except the tests.

// to the underlying Scrollable to remove an unwanted node
// in accessibility focus traversal.
scrollDirection: Axis.horizontal,
physics: ClampingScrollPhysics(),
Copy link
Member

Choose a reason for hiding this comment

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

Interesting — why this instead of the default?

(I believe the default would be this on Android, but BouncingScrollPhysics on iOS.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah yeah I think the default is fine actually :)

I was reading a tab-bar implementation (when doing the Semantics work for this PR) and I think it opted out of the "bouncing" behavior. But I'm not sure this is really a "tab bar" in the same sense as that code I was reading.

messageId: widget.messageId,
reactionType: reactionType,
emojiCode: emojiCode,
onRequestSelect: (r) => _setSelection(r),
Copy link
Member

Choose a reason for hiding this comment

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

nit: tearoff

Suggested change
onRequestSelect: (r) => _setSelection(r),
onRequestSelect: _setSelection,


/// Check that the given reaction still has votes;
/// if not, select a different one if possible or clear the selection.
void _reconcile() {
Copy link
Member

Choose a reason for hiding this comment

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

It looks like this always concludes with _setSelection. It might be a little clearer to read by making that explicit up front:

Suggested change
void _reconcile() {
void _reconcile() {
_setSelection(_findMatchingReaction());
}

Then _findMatchingReaction can directly return whenever it's found its answer.

Comment on lines 722 to 737
if (reactionType == null && widget.initialReactionType != null) {
assert(emojiCode == null);
assert(widget.initialEmojiCode != null);
reactionType = widget.initialReactionType!;
emojiCode = widget.initialEmojiCode!;
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't depend on the store, does it? It looks like it doesn't use the context at all; so it could run as early as initState.

And conversely it doesn't sound like it'd be desirable for this to run repeatedly, or after a new store — these are the initial emoji type and code, after all.

Comment on lines 741 to 744
return SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
Copy link
Member

Choose a reason for hiding this comment

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

Would this be equivalent?

Suggested change
return SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah no actually, and I think this is how it is in the released revision 🙂—it start-aligns the emoji items instead of center-aligning them.

Looks like I can get the desired behavior with CrossAxisAlignment.center instead of stretch, though.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, I don't quite understand the first sentence — can you spell out which version does what? (I.e. in "this is how" and "it start-aligns", what do "this" and "it" refer to?) Is the behavior in the release different from what was in this PR?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah sorry, yes that was confusing!

In 30.0.262, the items are start-aligned:

image

Here's code pasted from the 30.0.262 tag:

  Widget build(BuildContext context) {
    // TODO could pull out this layout/appearance code,
    //   focusing this widget only on state management
    return SizedBox(
      width: double.infinity,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisSize: MainAxisSize.min,
        children: [
          ViewReactionsHeader(widget.pageContext,

I want the items to be center-aligned, not start aligned. Column's default crossAxisAlignment is CrossAxisAlignment.center, so I can accomplish that with

    return SizedBox(
      width: double.infinity,
      child: Column(
        mainAxisSize: MainAxisSize.min,

It turned out that the SizedBox isn't necessary, and I can spell out CrossAxisAlignment.center if that's helpful. So, as I've had in recent revisions of this PR:

    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.center,

which center-aligns the items.

Copy link
Member

Choose a reason for hiding this comment

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

Very helpful, thanks! OK, so an earlier version of this PR (as seen in v30.0.262) had "stretch" alignment. That makes the children wide, including the header; and then I think the Row in the header, with defaults MainAxisSize.max and MainAxisAlignment.start, ends up putting the items at the start.

The revised version looks good.

(x) => x.reactionType == reactionType && x.emojiCode == emojiCode
)?.userIds.toList();

// (No filtering of muted or deactivated users.)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// (No filtering of muted or deactivated users.)
// (No filtering of muted or deactivated users.
// Muted users will be shown as muted.)

My first reaction to this comment was "is that right? seems like we shouldn't show muted users here." Then I remembered that that's probably handled at a different layer, and indeed it looks like it is.

Comment on lines 967 to 973
Widget result = InsetShadowBox(
top: 8, bottom: 8,
color: designVariables.bgContextMenu,
child: SizedBox(
height: 400, // TODO(design) tune
child: ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8),
Copy link
Member

Choose a reason for hiding this comment

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

nit: move SizedBox outward, so the InsetShadowBox is right next to (in the source code) the padding that needs to match it

Comment on lines 975 to 976
itemBuilder: (context, index) =>
ViewReactionsUserItem(context, userId: userIds[index]))));
Copy link
Member

Choose a reason for hiding this comment

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

Same comment as I told Sayed last week 🙂 : #1706 (comment)

(on code that I guess was modeled in part on this PR, so no coincidence)

Comment on lines 999 to 1002
Navigator.pop(pageContext);

Navigator.push(pageContext,
ProfilePage.buildRoute(context: pageContext, userId: userId));
Copy link
Member

Choose a reason for hiding this comment

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

I think the reason this works even though this pageContext isn't really a page context is that there's no await here.

The reason our action-sheet buttons often need a real page context is that they'll dismiss the action sheet, then do something that takes time like a network request, and then after that completes they need to use a context to do something with the result, or to show an error. So if the request takes longer than the few hundred ms of the dismiss animation, the action sheet's own context may be unmounted by that point.

For just synchronously acting on the navigation like this, the widget's own context is fine.

onTap: _onPressed,
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateColor.resolveWith((states) =>
states.any((e) => e == WidgetState.pressed)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
states.any((e) => e == WidgetState.pressed)
states.contains(WidgetState.pressed)

Looks like it's a Set, so that should be equivalent (and potentially faster)

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed. I noticed on reaction chips that the tooltip-on-long-press was sometimes winning out over the show-view-reactions-sheet, so I added a commit to get rid of the tooltip, with a test change for some tests that had been relying on it.

I also added a tweak to the scroll-into-view logic for the horizontal list of emojis, as a separate commit on top.

@chrisbobbe
Copy link
Collaborator Author

And I've just updated the screenshots.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks for the revision! Just a few comments.

Comment on lines 294 to 299
if (MediaQuery.accessibleNavigationOf(context)) {
// Avoid a _voterNames call with potentially many users,
// except in this case where a computation lag would be very small
// compared to the time saved in accessing the information.
result = Semantics(
label: _semanticsLabelWithVoterNames(store, zulipLocalizations),
Copy link
Member

Choose a reason for hiding this comment

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

Hmm. I agree the computation time might not be a big deal here. But this feels like it's making the UI more complicated to think about — if the user is using TalkBack or VoiceOver, it's presenting different information that isn't present in the UI we regularly use ourselves. I'd prefer to avoid that complication.

Can this instead just present the same information that's there in the main UI? It's true that getting the list of people who reacted is then an extra step or two for people using a screen-reader; but it's an extra step or two for everyone else, too, so that doesn't seem terrible.

Here's what the Flutter doc on the relevant MediaQueryData field says:

  /// Whether the user is using an accessibility service like TalkBack or
  /// VoiceOver to interact with the application.
  ///
  /// When this setting is true, features such as timeouts should be disabled or
  /// have minimum durations increased.

So this conditional seems rather afield from that.

Copy link
Member

Choose a reason for hiding this comment

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

(Or for now just present the same information that had been in the tooltip, namely the emoji name.)

Comment on lines 741 to 744
return SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
Copy link
Member

Choose a reason for hiding this comment

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

Sorry, I don't quite understand the first sentence — can you spell out which version does what? (I.e. in "this is how" and "it start-aligns", what do "this" and "it" refer to?) Is the behavior in the release different from what was in this PR?

@override
void initState() {
super.initState();
if (reactionType == null && widget.initialReactionType != null) {
Copy link
Member

Choose a reason for hiding this comment

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

The reactionType == null condition is always true now that this is in initState, right?

Comment on lines 798 to 806
/// When auto-scrolling an emoji into view,
/// this is where the scroll position will land
/// (the min- and max- scroll extent lerped to this value).
double _emojiItemPosition(int index, int aggregatedLength) {
Copy link
Member

Choose a reason for hiding this comment

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

lerped "at"?

Or some other/further rewording. I'd read "lerped to X" as meaning that X was the second argument of a lerp method — conceptually the arguments are start, end, and time.

Comment on lines 875 to 879
final destination = lerpDouble(
scrollPosition.minScrollExtent,
scrollPosition.maxScrollExtent,
position);
if (destination == null) return; // TODO(log)
Copy link
Member

Choose a reason for hiding this comment

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

This condition is impossible, right? (From the definition of lerpDouble.) So just use !.

This fixes the two inconsistencies flagged in discussion:
  https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/bottom.20sheet.20.22Cancel.22.2F.22Close.22.20button/near/2216116

> I think it's reasonable to have both labels, but I think we should
> choose them differently than now:
>
> - "Cancel" when the sheet is about doing an action: [etc.]
>
> - "Close" when the sheet just presents information or nav options:
>   [etc.]
…s test

So we can add another test that uses it.
We'll use this for a semantics label, coming up.
In a later commit in this series, we'll replace the long-press
action with showing a "view-reactions" sheet. We won't want
something else that's triggered on long-press, like this tooltip,
and the tooltip was sometimes winning the gesture-detection in my
testing at that later commit.

Remove the tooltip, replacing it with a semantics label, which tests
that were relying on the tooltip can use instead.
@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

}

Widget result = SizedBox(
height: 400,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh oops, I meant to leave a TODO(design) tune here (I think I had it in an earlier revision)—will put it back in my next revision

@gnprice
Copy link
Member

gnprice commented Aug 1, 2025

Thanks! All looks good to me. Please go ahead and merge after that comment tweak.

I experimented with using Semantics to help write human-centered
tests, and I ended up adding some configuration that actually seemed
to make a reasonable experience in the UI, at least in my testing
with VoiceOver.

Fixes zulip#740.
For motivation, see the comment on _scrollIntoView.
@chrisbobbe chrisbobbe merged commit 98b94bd into zulip:main Aug 2, 2025
1 check failed
@chrisbobbe
Copy link
Collaborator Author

Done, thanks!

@chrisbobbe chrisbobbe deleted the pr-see-who-reacted branch August 2, 2025 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

See who left an emoji reaction
2 participants