Skip to content

new_dm: Add UI for starting new DM conversations #1322

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

chimnayajith
Copy link
Contributor

@chimnayajith chimnayajith commented Feb 4, 2025

Pull Request

Description

This pull request adds the UI to starting new DM conversations.. A floating action button has been added to the RecentDmConversationsPage, which opens a bottom modal sheet, allowing users to select conversation participants and proceed to the MessageListPage.

Design reference: Figma

Related Issues

Screenshots

Light mode

Dark mode

Additional Notes

The user list is currently sorted based on the recency of direct messages.

@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Feb 4, 2025
@chimnayajith chimnayajith force-pushed the 127-new-dm branch 4 times, most recently from b407738 to d8818a2 Compare February 6, 2025 18:21
@PIG208 PIG208 self-requested a review February 12, 2025 20:46
@PIG208
Copy link
Member

PIG208 commented Feb 12, 2025

Thanks for working on this @chimnayajith!

I took a quick look at the implementation and checked the design. There are places where it currently does not match the Figma, for example:

          Container(
            width: MediaQuery.of(context).size.width,
            constraints: const BoxConstraints(
              minHeight: 44,
              maxHeight: 124,
            ),
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            decoration: BoxDecoration(
              color: designVariables.bgSearchInput,
            ),
            child: SingleChildScrollView(
              controller: scrollController,
              child: Row(
                children: [
                  Expanded(
                    child: Wrap(
                      spacing: 4,
                      runSpacing: 4,

Screenshot from 2025-02-12 17-49-54

where the horizontal padding should be 14px;

image

and the spacing between the pills should be 6px.

I think there might be more places like this, so please sure to check your PR with the design and make sure that they match exactly.

While working on the next revision, please also try to break down the sheet into reasonable widgets, instead of a single one that encompasses the entire new DM page. There are also things like collapsing the closing brackets into a single line; you can find examples of that throughout the codebase:

      // [...]
        child: SafeArea(
          child: SizedBox(height: 48,
            child: Center(
              child: ConstrainedBox(
                // TODO(design): determine a suitable max width for bottom nav bar
                constraints: const BoxConstraints(maxWidth: 600),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    for (final navigationBarButton in navigationBarButtons)
                      Expanded(child: navigationBarButton),
                  ])))))));

All those changes will help make your PR more reviewable.

At the high-level, I think the user filtering code should re-use the AutocompleteView API, similar to what EmojiPicker does. However, We don't have a implementation for user auto-completion. While MentionAutocompleteView is close, it is also responsible for other tasks. Supporting that might require a good amount of refactoring and is less suited for a new contributor. So let's just try to focus on getting the UI right for this PR.

@chimnayajith chimnayajith force-pushed the 127-new-dm branch 3 times, most recently from 0bc3e12 to a6cc695 Compare February 16, 2025 06:23
@chimnayajith
Copy link
Contributor Author

@PIG208 Thanks for the review! I’ve made the necessary changes to match the Figma, and refactored the sheet into smaller widgets. I also followed the code style guidelines you mentioned.

I’ve pushed the revision—PTAL.

Copy link
Member

@PIG208 PIG208 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 update! I went through the implementation of the design (mainly layout but not the colors) and left some comments. I haven't read the tests yet, but it should be a good amount of feedback to work on a new revision for.

Comment on lines 70 to 71
width: 137,
height: 48,
Copy link
Member

Choose a reason for hiding this comment

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

Instead of setting the size definitely, let's express this in terms of paddings and minWidth/minHeight.

image

Because the "new dm" string can get translated (thus ends up having different width, or the user has a non-default font size, the actual width and height can vary.

Comment on lines 76 to 77
icon: Icon(Icons.add, size: 24),
label: Text(zulipLocalizations.newDmFabButtonLabel, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
Copy link
Member

Choose a reason for hiding this comment

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

Do we have a way to control/verify the spacing between the icon and the label? In Figma it's 8px but from this code it is not obvious if it complies to the design.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The extendedIconLabelSpacing field for extended floating action buttons has a default value of 8.0. Should it still be specified?

Copy link
Member

@PIG208 PIG208 Feb 25, 2025

Choose a reason for hiding this comment

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

Yeah. Because the default value can change (though unlikely), but we have a specific value in mind.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have given it in the revision pushed.


void showNewDmSheet(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
showModalBottomSheet<dynamic>(
Copy link
Member

Choose a reason for hiding this comment

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

Let's avoid dynamic. We in general follow Flutter's style guide.

@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.95,
Copy link
Member

Choose a reason for hiding this comment

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

It doesn't seem right to size it this way. If the goal is to avoid the top of the bottom sheet overlapping with the device's top inset, see useSafeArea on showModalBottomSheet.

}
}

class NewDmHeader extends StatelessWidget {
Copy link
Member

Choose a reason for hiding this comment

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

For helper widgets like this, let's make them private (_NewDmHeader) unless it is otherwise necessary.

hintStyle: TextStyle(
fontSize: 17,
height: 1.0,
color: designVariables.textInput.withFadedAlpha(0.5)),
Copy link
Member

Choose a reason for hiding this comment

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

Looks like we do have a Figma variable for this: label-search-prompt.

children: [
Avatar(userId: userId, size: 22, borderRadius: 3),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
Copy link
Member

Choose a reason for hiding this comment

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

The end inset is not 5px:

image

Suggested change
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
padding: EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3),

We use EdgeInsetsDirectional because the start/end insets are different. This is for RTL where left and right are flipped.


final List<User> filteredUsers;
final Set<int> selectedUserIds;
final void Function(int) onUserSelected;
Copy link
Member

Choose a reason for hiding this comment

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

The name of this is a bit misleading, because "selected" seems to indicate that the user is added to the list of selected user, when in reality it is more like toggling the selection of the user.

How about something like void Function(int userId) onUserTapped, with the parameter name.

Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);

return ListView.builder(
Copy link
Member

Choose a reason for hiding this comment

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

There is an 8px top padding that applies to the scrollable:

image

I assume that's something that allows you to scroll over. Perhaps ListView.padding will be helpful.

Comment on lines 303 to 306
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
Copy link
Member

Choose a reason for hiding this comment

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

Is the outer Padding redundant?

});
}

// Scroll to the search field when the user selects a user
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, is this part of the UX specified in the design? It would be good to link to the relevant discussion/Figma if that's the case. Either way, I feel that it might be the best to leave this out from the initial implementation, to simplify things as we work it out.

size: 24,
color: selectedUserIds.isEmpty
? designVariables.icon.withFadedAlpha(0.5)
: designVariables.icon)]));
Copy link
Member

Choose a reason for hiding this comment

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

Square brackets are usually not wrapped, because they help indicate the end of a list and that the items before them are parallel.

super.key,
required this.searchController,
required this.scrollController,
required this.selectedUserIds});
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, curly brackets are not wrapped, because they indicate the end of a parameter list, function, etc.

@chimnayajith chimnayajith force-pushed the 127-new-dm branch 3 times, most recently from 9439a4a to e033db5 Compare February 24, 2025 16:10
@chimnayajith chimnayajith requested a review from PIG208 February 25, 2025 15:16
@chimnayajith
Copy link
Contributor Author

@PIG208 Pushed a revision. PTAL!

@alya
Copy link
Collaborator

alya commented Feb 26, 2025

@chimnayajith Are the screenshots in the PR description up to date? Please update them if not.

@chimnayajith
Copy link
Contributor Author

chimnayajith commented Feb 28, 2025

I've updated the screenshots. Please take a look!

@alya
Copy link
Collaborator

alya commented Feb 28, 2025

Let's change "Add Person" to "Add user", which will be more consistent with the web app. I'm not sure why your screenshots have varied capitalization, but in any case, only the first word should be capitalized.

@alya
Copy link
Collaborator

alya commented Feb 28, 2025

Actually, it would probably be better to match the web app more fully, if it doesn't feel too long in the mobile context.

  • "Add one or more users" before anyone is selected
  • "Add another user…" once someone is selected

@alya
Copy link
Collaborator

alya commented Feb 28, 2025

Why is there no "Add Person" in your last screenshot? What is different vs. the other ones?

@chimnayajith
Copy link
Contributor Author

Continuing the discussion in CZO

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks! This looks a lot closer to the design now and I left some small comments on the implementation. This time, I also reviewed the tests.

child: Text(zulipLocalizations.newDmSheetScreenTitle,
style: TextStyle(color: designVariables.title,
fontSize: 20, height: 30 / 20)
.merge(weightVariableTextStyle(context, wght: 600)),
Copy link
Member

Choose a reason for hiding this comment

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

nit: indentation

}

class _SelectedUserChip extends StatelessWidget {
const _SelectedUserChip({ required this.userId });
Copy link
Member

Choose a reason for hiding this comment

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

nit: remove spaces around the parameter

@@ -197,6 +203,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
contextMenuItemMeta: const Color(0xff9194a3),
contextMenuItemText: const Color(0xff9398fd),
editorButtonPressedBg: Colors.white.withValues(alpha: 0.06),
fabBg: const Color(0xff4f42c9),
fabBgPressed: const Color(0xff6159e1),
Copy link
Member

Choose a reason for hiding this comment

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

The dark mode color of this doesn't match the design:

image

@@ -197,6 +203,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
contextMenuItemMeta: const Color(0xff9194a3),
contextMenuItemText: const Color(0xff9398fd),
editorButtonPressedBg: Colors.white.withValues(alpha: 0.06),
fabBg: const Color(0xff4f42c9),
fabBgPressed: const Color(0xff6159e1),
fabLabel: const Color(0xffeceefc),
Copy link
Member

Choose a reason for hiding this comment

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

There is also a pressed color variant for fabLabel:

image

await runAndCheck(tester, user: user);
});
});
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: need a newline after this

}

testWidgets('navigates to message list on Next', (tester) async {
final user = eg.user(userId: 1, fullName: 'Test User');
Copy link
Member

Choose a reason for hiding this comment

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

We can skip specifying userId because we don't rely on it being this particular value for the test.

Comment on lines 73 to 75
eg.user(userId: 1, fullName: 'Alice Anderson'),
eg.user(userId: 2, fullName: 'Bob Brown'),
eg.user(userId: 3, fullName: 'Alice Carter'),
Copy link
Member

Choose a reason for hiding this comment

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

nit: we can skip specifying user IDs here

Comment on lines 29 to 31
for (final user in users) {
await store.addUser(user);
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: use store.addUsers instead

check(nextButton.onTap).isNull();
});

testWidgets('shows filtered users based on search', (tester) async {
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 there are some other interesting filtering logic that can be tested. Such as checking that the self user is excluded from the list, the search is case-insensitive. Another useful test would be checking if unknown users are handled correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't able to write tests specifically for handling unknown users, but the logic relies on userDisplayName, which is already covered in user_test.dart. I also didn’t find any other tests that handle this specifically.

Copy link
Member

Choose a reason for hiding this comment

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

I realized that it's a product question as to whether we want to allow selecting the self user. I think we should, since that's allowed on web and the legacy app.

});

final BuildContext pageContext;
final PerAccountStore store;
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 we can skip passing this by replacing the _updateFilteredUsers call with an overridden didChangeDependencies.

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _handleSearchUpdate();
  }

@PIG208
Copy link
Member

PIG208 commented Apr 17, 2025

Hi @chimnayajith! Thanks for working on this. Since this issue is a launch blocker, we would prioritize reviewing this/getting this done first; the other issue that you are working on (#1344) is a launch goal, which means that it will likely receive less attention until we clear the blockers.

@chimnayajith chimnayajith force-pushed the 127-new-dm branch 2 times, most recently from 61ede77 to 82b1b66 Compare April 20, 2025 20:13
@chimnayajith
Copy link
Contributor Author

chimnayajith commented Apr 20, 2025

@PIG208 Pushed a revision. PTAL.

I had missed the "No users found" screen earlier; just added it and included the screenshots in the PR description. There aren’t any design references for it, but it follows how it’s done in the RN app

Comment on lines +311 to +337
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
zulipLocalizations.newDmSheetNoUsersFound,
style: TextStyle(
color: designVariables.labelMenuButton,
fontSize: 16))));
Copy link
Member

Choose a reason for hiding this comment

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

This will need a TODO(design) comment since it is not a part of the Figma design.

Comment on lines 332 to 334
decoration: isSelected ? BoxDecoration(
color: designVariables.bgMenuButtonSelected,
borderRadius: BorderRadius.circular(10)) : null,
Copy link
Member

Choose a reason for hiding this comment

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

nit: Container shouldn't be necessary; we can use DecoratedBox

another nit: I think we usually have the null case first, for reasons similar to why early returns are preferred.

Comment on lines 355 to 356
color: designVariables.textMessage)
.merge(weightVariableTextStyle(context, wght: 500))))),
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
color: designVariables.textMessage)
.merge(weightVariableTextStyle(context, wght: 500))))),
color: designVariables.textMessage,
).merge(weightVariableTextStyle(context, wght: 500))))),

This way the end boundary of TextStyle is clear.

check(nextButton.onTap).isNull();
});

testWidgets('shows filtered users based on search', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

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

I realized that it's a product question as to whether we want to allow selecting the self user. I think we should, since that's allowed on web and the legacy app.

Comment on lines 229 to 238
if (users.length == 1) {
check(find.descendant(
of: find.byType(ZulipAppBar),
matching: find.text('DMs with ${users[0].fullName}'))).findsOne();
} else {
final names = users.map((user) => user.fullName).join(', ');
check(find.descendant(
of: find.byType(ZulipAppBar),
matching: find.text('DMs with $names'))).findsOne();
}
Copy link
Member

Choose a reason for hiding this comment

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

It will probably be easier to pass the expected app bar title from the tests.

Copy link
Member

@PIG208 PIG208 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 update! We have gone through the design in previous rounds. In this review I focused mostly on new changes like _NewDmButton and the tests.

onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
borderRadius: BorderRadius.circular(28),
child: Container(
Copy link
Member

Choose a reason for hiding this comment

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

Let's use the animated version AnimatedContainer here. The Figma design has specified the duration and curve for transitions.

Looks like it also handles the box shadow animation properly, so we can just complete the TODO from #1322 (comment), now that we do have a AnimatedScaleOnTap style widget.

zulipLocalizations.newDmFabButtonLabel,
style: TextStyle(fontSize: 20, height: 24 / 20)
.merge(weightVariableTextStyle(context, wght: 500))
.copyWith(color: fabLabelColor)),
Copy link
Member

Choose a reason for hiding this comment

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

It should be fine to just specify the color when constructing TextStyle, right?

@@ -138,3 +149,55 @@ class RecentDmConversationsItem extends StatelessWidget {
]))));
}
}

class _NewDmButton extends StatefulWidget {
final VoidCallback onPressed;
Copy link
Member

Choose a reason for hiding this comment

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

nit: inline the callback in the _NewDmButtonState.build so that we can remove this parameter; it seems fitting that this widget is responsible for calling showNewDmSheet

Comment on lines 169 to 170
final fabBgColor = _pressed ? designVariables.fabBgPressed : designVariables.fabBg;
final fabLabelColor = _pressed ? designVariables.fabLabelPressed : designVariables.fabLabel;
Copy link
Member

Choose a reason for hiding this comment

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

nit: these lines are too long; wrap them so that they stay under 80 characters in width

import 'test_app.dart';

Future<void> setupSheet(WidgetTester tester, {
required List<User> users
Copy link
Member

Choose a reason for hiding this comment

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

nit: missing trailing comma

Comment on lines 144 to 150
testWidgets('selecting a user', (tester) async {
final user = eg.user(fullName: 'Test User');
await setupSheet(tester, users: [user]);
Finder findNextButton() => find.widgetWithText(GestureDetector, 'Next');

check(find.byIcon(Icons.circle_outlined)).findsOne();
check(find.byIcon(Icons.check_circle)).findsNothing();

var nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNull();
final userTile = find.ancestor(
of: find.text(user.fullName),
matching: find.byType(InkWell));
await tester.tap(userTile);
await tester.pump();
check(find.byIcon(Icons.check_circle)).findsOne();
check(find.byIcon(Icons.circle_outlined)).findsNothing();
nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNotNull();
});
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
testWidgets('selecting a user', (tester) async {
final user = eg.user(fullName: 'Test User');
await setupSheet(tester, users: [user]);
Finder findNextButton() => find.widgetWithText(GestureDetector, 'Next');
check(find.byIcon(Icons.circle_outlined)).findsOne();
check(find.byIcon(Icons.check_circle)).findsNothing();
var nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNull();
final userTile = find.ancestor(
of: find.text(user.fullName),
matching: find.byType(InkWell));
await tester.tap(userTile);
await tester.pump();
check(find.byIcon(Icons.check_circle)).findsOne();
check(find.byIcon(Icons.circle_outlined)).findsNothing();
nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNotNull();
});
testWidgets('selecting a user', (tester) async {
final user = eg.user(fullName: 'Test User');
final userTileFinder = find.ancestor(
of: find.text(user.fullName),
matching: find.byType(InkWell));
await setupSheet(tester, users: [user]);
final nextButton = tester.widget<GestureDetector>(
find.widgetWithText(GestureDetector, 'Next'));
check(find.byIcon(Icons.circle_outlined)).findsOne();
check(find.byIcon(Icons.check_circle)).findsNothing();
check(nextButton.onTap).isNull();
await tester.tap(userTileFinder);
await tester.pump();
check(find.byIcon(Icons.circle_outlined)).findsNothing();
check(find.byIcon(Icons.check_circle)).findsOne();
check(nextButton.onTap).isNotNull();
});

By breaking the checks into two stanzas, we can make this a bit more readable. (We don't need to call tester.widget() twice to see updates to nextButton.onTap.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't need to call tester.widget() twice to see updates to nextButton.onTap.

I tried implementing this change, but I'm running into some issues - the tests are failing when I remove the second call.
When I only get the button once before the tap and then check its onTap property after the UI update, the test fails as if the property isn't being updated.
Could you help me understand the correct approach here?

Copy link
Member

Choose a reason for hiding this comment

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

I see. Yeah, it looks like the return values of the tester.widget call before and after the pump are different. Let's keep calling them again then.


testWidgets('opens new DM sheet on FAB tap', (tester) async {
await setupPage(tester, users: [], dmMessages: []);
final fab = find.ancestor(
Copy link
Member

Choose a reason for hiding this comment

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

nit: Let's avoid abbreviations like this (here and in the test name) when possible, unless it comes from an outside source like the Figma design. In this case, we are just referring to the new DM button widget.

@chimnayajith chimnayajith force-pushed the 127-new-dm branch 4 times, most recently from d221599 to 7eb7984 Compare April 28, 2025 14:06
@chimnayajith
Copy link
Contributor Author

@PIG208 Pushed a revision. PTAL!

Copy link
Member

@PIG208 PIG208 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 update! Left some comments, most are small, except for ones on adding more test cases.

Comment on lines 78 to 95
bool shouldIncludeSelfUser(User user) {
final shouldExcludeSelfUser = selectedUserIds.isNotEmpty
&& !selectedUserIds.contains(store.selfUserId);

if (user.userId != store.selfUserId) return true;
return !shouldExcludeSelfUser;
}
Copy link
Member

Choose a reason for hiding this comment

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

This seems a bit complicated. From reading this, it seems that if user is selfUserId, this will only return false when selectedUserIds is non-empty and contains selfUserId.

However, I think the shouldIncludeUser(user) check can just be removed from the .where condition, since the intention is to just not special-case self-user when filtering by user name.

Comment on lines 115 to 118
if (userId != store.selfUserId
&& selectedUserIds.contains(store.selfUserId)) {
selectedUserIds.remove(store.selfUserId);
}
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, it will probably be simpler if we don't special-case self user ID. The UX here seems a bit confusing when I tried it out: if myself is selected, then I go on to select another user, myself is deselected. This seemed a bit surprising.

decoration: !isSelected
? const BoxDecoration()
: BoxDecoration(color: designVariables.bgMenuButtonSelected,
borderRadius: BorderRadius.circular(10)),
Copy link
Member

Choose a reason for hiding this comment

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

nit: indentation

borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 6, 12, 6),
child:Row(
Copy link
Member

Choose a reason for hiding this comment

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

nit: missing space

Comment on lines 176 to 174
final narrow = DmNarrow.withOtherUsers(
selectedUserIds,
selfUserId: store.selfUserId);
Copy link
Member

Choose a reason for hiding this comment

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

Because now selectedUserIds can contain selfUserId, this needs to change. See the dartdoc of DmNarrow.withOtherUsers.


check(find.byType(ComposeBox)).findsOne();
}

Copy link
Member

Choose a reason for hiding this comment

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

Let's also test the cases when only the selfUser is selected, and when the selfUser is selected with some other users.

Comment on lines 144 to 150
testWidgets('selecting a user', (tester) async {
final user = eg.user(fullName: 'Test User');
await setupSheet(tester, users: [user]);
Finder findNextButton() => find.widgetWithText(GestureDetector, 'Next');

check(find.byIcon(Icons.circle_outlined)).findsOne();
check(find.byIcon(Icons.check_circle)).findsNothing();

var nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNull();
final userTile = find.ancestor(
of: find.text(user.fullName),
matching: find.byType(InkWell));
await tester.tap(userTile);
await tester.pump();
check(find.byIcon(Icons.check_circle)).findsOne();
check(find.byIcon(Icons.circle_outlined)).findsNothing();
nextButton = tester.widget<GestureDetector>(findNextButton());
check(nextButton.onTap).isNotNull();
});
Copy link
Member

Choose a reason for hiding this comment

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

I see. Yeah, it looks like the return values of the tester.widget call before and after the pump are different. Let's keep calling them again then.

filteredUsers = store.allUsers
.where((user) =>
shouldIncludeSelfUser(user) &&
!user.isBot &&
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 we shouldn't exclude bot users. Some bots might actually utilize DMs as a way of receiving commands from the user.

textAlign: TextAlign.center)),
_buildNextButton(context),
])
);
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 trailing parenthesis to the previous line

Comment on lines 189 to 190
? const Color(0xff2B0E8A).withValues(alpha: 0.3)
: const Color(0xff2B0E8A).withValues(alpha: 0.4),
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, so these are not named variables in the Figma design. In dark mode, the shadow is barely visible. Let's also have a TODO(design) comment here, mentioning dark mode colors. Perhaps it is also helpful to discussion in #mobile-design on whether we want the shadows in dark mode or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Opened a design thread for this in CZO here

@chimnayajith
Copy link
Contributor Author

Started a CZO thread to discuss the filtering logic.

@chimnayajith
Copy link
Contributor Author

@PIG208 Pushed a revision. PTAL!

Copy link
Member

@PIG208 PIG208 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 revisions! Just a handful of comments.


await tester.tap(userTileFinder);
await tester.pump();

Copy link
Member

Choose a reason for hiding this comment

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

We should check nextButton here as well, so that we know that it's precisely the second tap that toggles the user selection

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't the suggested change make the 'deselecting a user' test redundant with the 'selecting a user' test, since the latter already checks that nextButton.onTap becomes non-null after selection?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, that's true. I think this probably suggests that these two tests can be combined together.

await tester.pump();
check(find.descendant(
of: selfUserTileFinder,
matching: find.byIcon(Icons.check_circle))).findsOne();
Copy link
Member

Choose a reason for hiding this comment

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

We should check eg.selfUser.fullName here too.

Comment on lines 279 to 261
});
testWidgets('navigates to group DM on Next', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

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

nit: missing newline

tester,
users: [],
expectedAppBarTitle: 'DMs with yourself',
isSelfDm: true);
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, how about just passing users: [eg.selfUser]? That way we don't need to introduce a separate parameter.

Comment on lines 166 to 194
final fabBgColor = _pressed
? designVariables.fabBgPressed
: designVariables.fabBg;
final fabLabelColor = _pressed
? designVariables.fabLabelPressed
: designVariables.fabLabel;
final fabShadowColor = _pressed
? designVariables.fabShadowLow
: designVariables.fabShadowHigh;

return InkWell(
onTap: () => showNewDmSheet(context),
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
borderRadius: BorderRadius.circular(28),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 20, 12),
decoration: BoxDecoration(
color: fabBgColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
// TODO(design): Shadow colors barely visible in dark mode
color: fabShadowColor,
blurRadius: _pressed ? 12 : 16,
spreadRadius: 0,
offset: _pressed
? const Offset(0, 2)
: const Offset(0, 4)),
]),
Copy link
Member

Choose a reason for hiding this comment

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

Let's make boxShadow (normal and pressed states) design variables. We can use these values in light mode, and simply null in dark mode. See ComposeBoxTheme for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking at the design discussion on CZO thread it appears the decision is to have shadows in dark mode as well.

Given this, do I still need to follow the ComposeBoxTheme pattern with a separate theme extension class, or should I simply continue using designVariables.fabShadow directly in the button code as currently implemented?

Copy link
Member

Choose a reason for hiding this comment

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

Good question. To be clear, my suggestion was to just extract them to DesignVariables, not a separate theme class.

We usually prefer matching the name of design variables in code to Figma. Now that we do have such a variable, and that they are not otherwise different in light vs. dark mode, it should be fine without refactoring.

Add a modal bottom sheet UI for starting direct messages:
- Search and select users from global list
- Support single and group DMs
- Navigate to message list after selection

Design reference: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4903-31879&p=f&t=pQP4QcxpccllCF7g-0
Fixes: zulip#127
@chimnayajith
Copy link
Contributor Author

@PIG208 Pushed a revision. PTAL!

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks! Just a comment on the l10n descriptions. Marking this for Greg's review.

Comment on lines +366 to +370
"description": "Hint text for the search bar when no users are selected"
},
"newDmSheetSearchHintSomeSelected": "Add another user…",
"@newDmSheetSearchHintSomeSelected": {
"description": "Hint text for the search bar when at least one user is selected"
Copy link
Member

Choose a reason for hiding this comment

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

nit: missing period at the end of the sentence

@PIG208 PIG208 added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels May 12, 2025
@PIG208 PIG208 assigned gnprice and unassigned PIG208 May 12, 2025
@PIG208 PIG208 requested a review from gnprice May 12, 2025 16:20
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 @chimnayajith for building this, and thanks @PIG208 for the previous reviews!

Comments below. For this round, I've read the main new file down through the header and serach bar. Left for a future round are the user list, the changes in other widgets files, and the tests.

Comment on lines +83 to +84
if (user.fullName.toLowerCase().contains(
searchController.text.toLowerCase())) {
Copy link
Member

Choose a reason for hiding this comment

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

The searchController.text.toLowerCase() can be brought outside the loop, so that we do that computation just once instead of once per user.

Comment on lines +93 to +98
if (aLatestMessageId != null && bLatestMessageId != null) {
return bLatestMessageId.compareTo(aLatestMessageId);
}
if (aLatestMessageId != null) return -1;
if (bLatestMessageId != null) return 1;
return 0;
Copy link
Member

Choose a reason for hiding this comment

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

This is some fairly opaque logic. It's hard to tell if what it's doing is correct. A major reason why that's hard is that it's not explicit just what this is trying to do — so the reader has to guess, and then compare the logic they see to their guess at the intent, and if those don't match they can't be sure whether they've found a bug or their guess at the intent was wrong, so they may have to repeat several times with different guesses.

The main tactic for making this kind of code clear is to put it in its own method, with a clear name and doc comment.

We already have a method that does that, namely MentionAutocompleteView.compareRecentMessageIds, and its caller compareByDms which corresponds to this whole sort function. So it'd be best to just call MentionAutocompleteView.compareByDms.

Comment on lines +75 to +76
// TODO: switch to using an `AutocompleteView` for users
void _updateFilteredUsers(PerAccountStore store) {
Copy link
Member

Choose a reason for hiding this comment

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

Even though in this version we're not using the full machinery of AutocompleteView, let's do apply one of the key optimizations that MentionAutocompleteView makes:

  • Have a List<User> sortedUsers. Compute that just once, at the start of the interaction, with a full list of all users.
  • Then when the user edits their search filter, compute filteredUsers by going through that list, without repeating the sorting.


void _handleUserTap(int userId) {
final store = PerAccountStoreWidget.of(context);
final newSelectedUserIds = Set<int>.from(selectedUserIds);
Copy link
Member

Choose a reason for hiding this comment

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

Instead of copying this Set, we can mutate the existing one. We don't have a need for the old value.

Comment on lines +175 to +177
Navigator.pop(context);
Navigator.push(context,
MessageListPage.buildRoute(context: context, narrow: narrow));
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps Navigator.pushReplacement? How does the behavior differ between pop-then-push and that method?

Comment on lines +266 to +267
child: Row(children: [
Expanded(child: Wrap(
Copy link
Member

Choose a reason for hiding this comment

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

Is this a Row that always has one child? What if we just use the child directly?

Comment on lines +258 to +262
return Container(
constraints: const BoxConstraints(
minHeight: 44,
maxHeight: 124),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
Copy link
Member

Choose a reason for hiding this comment

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

Do these constraints apply to the size including the padding, or excluding it?

My guess is they include the padding. But I'm not sure. I'd have to study the Container docs, and/or implementation, to be sure.

Instead let's handle the constraints and the padding in two separate widgets. That way it'll be very clear right here in our code which one is on the outside of which.

color: designVariables.labelSearchPrompt,
fontSize: 17,
height: 22 / 17),
border: InputBorder.none));
Copy link
Member

Choose a reason for hiding this comment

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

nit: group properties logically; border is closely related to contentPadding (much more so than either of them is to the hint), so make them adjacent

Comment on lines +296 to +299
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(userId: userId, size: 22, borderRadius: 3),
Copy link
Member

Choose a reason for hiding this comment

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

nit: can make compact and reduce indentation:

Suggested change
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(userId: userId, size: 22, borderRadius: 3),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Avatar(userId: userId, size: 22, borderRadius: 3),

Avatar(userId: userId, size: 22, borderRadius: 3),
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3),
child: Text(user?.fullName ?? zulipLocalizations.unknownUserName,
Copy link
Member

Choose a reason for hiding this comment

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

We have a centralized getter for this. (It might be new since you began work on this feature.) Search for references to unknownUserName.

@gnprice
Copy link
Member

gnprice commented May 13, 2025

I have this running now in the build on my device.

One quick comment from playing with it: when I select a user from the list of results, the search text I've entered should get cleared. Effectively by choosing a user I'm completing the name I had started typing as text.

fontSize: 20,
height: 30 / 20,
).merge(weightVariableTextStyle(context, wght: 600)),
overflow: TextOverflow.ellipsis,
Copy link
Member

Choose a reason for hiding this comment

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

Does this overflow parameter have any effect without maxLines? Reading docs, I believe it doesn't.

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.

Support starting a new DM conversation
5 participants