diff --git a/lib/model/store.dart b/lib/model/store.dart index 94f6c56084..5d7fcda117 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -498,6 +498,33 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } + /// The given user's real email address, if known, for displaying in the UI. + /// + /// Returns null if self-user isn't able to see [user]'s real email address. + String? userDisplayEmail(User user) { + if (zulipFeatureLevel >= 163) { // TODO(server-7) + // A non-null value means self-user has access to [user]'s real email, + // while a null value means it doesn't have access to the email. + // Search for "delivery_email" in https://zulip.com/api/register-queue. + return user.deliveryEmail; + } else { + if (user.deliveryEmail != null) { + // A non-null value means self-user has access to [user]'s real email, + // while a null value doesn't necessarily mean it doesn't have access + // to the email, .... + return user.deliveryEmail; + } else if (emailAddressVisibility == EmailAddressVisibility.everyone) { + // ... we have to also check for [PerAccountStore.emailAddressVisibility]. + // See: + // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 + // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 + return user.email; + } else { + return null; + } + } + } + //////////////////////////////// // Streams, topics, and stuff about them. diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 5da053c329..e34946a31f 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -11,6 +11,8 @@ import '../model/autocomplete.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import 'compose_box.dart'; +import 'text.dart'; +import 'theme.dart'; abstract class AutocompleteField extends StatefulWidget { const AutocompleteField({ @@ -218,6 +220,8 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( option: option, narrow: narrow), @@ -227,6 +231,9 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9) final localizations = ZulipLocalizations.of(context); - final description = switch (wildcardOption) { + return switch (wildcardOption) { WildcardMentionOption.all || WildcardMentionOption.everyone => isDmNarrow ? localizations.wildcardMentionAllDmDescription : isChannelWildcardAvailable @@ -256,32 +263,61 @@ class _MentionAutocompleteItem extends StatelessWidget { : localizations.wildcardMentionStreamDescription, WildcardMentionOption.topic => localizations.wildcardMentionTopicDescription, }; - return Text.rich(TextSpan(text: '${wildcardOption.canonicalString} ', children: [ - TextSpan(text: description, style: TextStyle(fontSize: 12, - color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.8)))])); } @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + Widget avatar; - Widget label; + String label; + String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult - avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px - label = Text(user.fullName); + avatar = Avatar(userId: userId, size: 36, borderRadius: 4); + label = user.fullName; + sublabel = store.userDisplayEmail(user); case WildcardMentionAutocompleteResult(:var wildcardOption): - avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px - label = wildcardLabel(wildcardOption, context: context, store: store); + avatar = SizedBox.square(dimension: 36, + child: const Icon(ZulipIcons.three_person, size: 24)); + label = wildcardOption.canonicalString; + sublabel = wildcardSublabel(wildcardOption, context: context, store: store); } + final labelWidget = Text( + label, + style: TextStyle( + fontSize: 18, + height: 20 / 18, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, + wght: sublabel == null ? 500 : 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1); + + final sublabelWidget = sublabel == null ? null : Text( + sublabel, + style: TextStyle( + fontSize: 14, + height: 16 / 14, + color: designVariables.contextMenuItemMeta), + overflow: TextOverflow.ellipsis, + maxLines: 1); + return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4), child: Row(children: [ avatar, - const SizedBox(width: 8), - label, + const SizedBox(width: 6), + Expanded(child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelWidget, + if (sublabelWidget != null) sublabelWidget, + ])), ])); } } @@ -291,12 +327,13 @@ class _EmojiAutocompleteItem extends StatelessWidget { final EmojiAutocompleteResult option; - static const _size = 32.0; - static const _notoColorEmojiTextSize = 25.7; + static const _size = 24.0; + static const _notoColorEmojiTextSize = 19.3; @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); final candidate = option.candidate; // TODO deduplicate this logic with [EmojiPickerListEntry] @@ -315,15 +352,26 @@ class _EmojiAutocompleteItem extends StatelessWidget { ? candidate.emojiName : [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080) + // TODO(design): emoji autocomplete results + // There's no design in Figma for emoji autocomplete results. + // Instead we adapt the design for the emoji picker to the + // context of autocomplete results as exemplified by _MentionAutocompleteItem. + // That means: emoji size, text size, text line-height, and font weight + // from emoji picker; text color (for contrast with background) and + // outer padding from _MentionAutocompleteItem; padding around emoji glyph + // to bring it to same size as avatar in _MentionAutocompleteItem. return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Row(children: [ if (glyph != null) ...[ - glyph, - const SizedBox(width: 8), + Padding(padding: const EdgeInsets.all(6), + child: glyph), + const SizedBox(width: 6), ], Expanded( child: Text( + style: TextStyle(fontSize: 17, height: 18 / 17, + color: designVariables.contextMenuItemLabel), maxLines: 2, overflow: TextOverflow.ellipsis, label)), diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 00620cb82d..f1328b3367 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -2,12 +2,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/content.dart'; import '../model/narrow.dart'; -import '../model/store.dart'; import 'app_bar.dart'; import 'content.dart'; import 'message_list.dart'; @@ -36,32 +34,6 @@ class ProfilePage extends StatelessWidget { page: ProfilePage(userId: userId)); } - /// The given user's real email address, if known, for displaying in the UI. - /// - /// Returns null if self-user isn't able to see [user]'s real email address. - String? _getDisplayEmailFor(User user, {required PerAccountStore store}) { - if (store.zulipFeatureLevel >= 163) { // TODO(server-7) - // A non-null value means self-user has access to [user]'s real email, - // while a null value means it doesn't have access to the email. - // Search for "delivery_email" in https://zulip.com/api/register-queue. - return user.deliveryEmail; - } else { - if (user.deliveryEmail != null) { - // A non-null value means self-user has access to [user]'s real email, - // while a null value doesn't necessarily mean it doesn't have access - // to the email, .... - return user.deliveryEmail; - } else if (store.emailAddressVisibility == EmailAddressVisibility.everyone) { - // ... we have to also check for [PerAccountStore.emailAddressVisibility]. - // See: - // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 - // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 - return user.email; - } else { - return null; - } - } - } @override Widget build(BuildContext context) { @@ -72,7 +44,7 @@ class ProfilePage extends StatelessWidget { return const _ProfileErrorPage(); } - final displayEmail = _getDisplayEmailFor(user, store: store); + final displayEmail = store.userDisplayEmail(user); final items = [ Center( child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ed3380e005..3fb70365c8 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -136,6 +136,8 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), + contextMenuItemLabel: const Color(0xff242631), + contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), foreground: const Color(0xff000000), @@ -184,6 +186,8 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), + contextMenuItemLabel: const Color(0xffdfe1e8), + contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), foreground: const Color(0xffffffff), @@ -240,6 +244,8 @@ class DesignVariables extends ThemeExtension { required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, + required this.contextMenuItemLabel, + required this.contextMenuItemMeta, required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, @@ -297,6 +303,8 @@ class DesignVariables extends ThemeExtension { final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; + final Color contextMenuItemLabel; + final Color contextMenuItemMeta; final Color contextMenuItemText; final Color editorButtonPressedBg; final Color foreground; @@ -349,6 +357,8 @@ class DesignVariables extends ThemeExtension { Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, + Color? contextMenuItemLabel, + Color? contextMenuItemMeta, Color? contextMenuItemText, Color? editorButtonPressedBg, Color? foreground, @@ -396,7 +406,9 @@ class DesignVariables extends ThemeExtension { composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, - contextMenuItemText: contextMenuItemText ?? this.contextMenuItemBg, + contextMenuItemLabel: contextMenuItemLabel ?? this.contextMenuItemLabel, + contextMenuItemMeta: contextMenuItemMeta ?? this.contextMenuItemMeta, + contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, icon: icon ?? this.icon, @@ -450,7 +462,9 @@ class DesignVariables extends ThemeExtension { composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, - contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemBg, t)!, + contextMenuItemLabel: Color.lerp(contextMenuItemLabel, other.contextMenuItemLabel, t)!, + contextMenuItemMeta: Color.lerp(contextMenuItemMeta, other.contextMenuItemMeta, t)!, + contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, icon: Color.lerp(icon, other.icon, t)!, diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 8dc24d239e..2e0ad50156 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -14,7 +14,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/compose_box.dart'; -import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; import '../api/fake_api.dart'; @@ -145,11 +145,13 @@ void main() { TestZulipBinding.ensureInitialized(); group('@-mentions', () { - void checkUserShown(User user, PerAccountStore store, {required bool expected}) { - check(find.text(user.fullName).evaluate().length).equals(expected ? 1 : 0); - final avatarFinder = - findNetworkImage(store.tryResolveUrl(user.avatarUrl!).toString()); - check(avatarFinder.evaluate().length).equals(expected ? 1 : 0); + + Finder findAvatarImage(int userId) => + find.byWidgetPredicate((widget) => widget is AvatarImage && widget.userId == userId); + + void checkUserShown(User user, {required bool expected}) { + check(find.text(user.fullName)).findsExactly(expected ? 1 : 0); + check(findAvatarImage(user.userId)).findsExactly(expected ? 1 : 0); } testWidgets('user options appear, disappear, and change correctly', (tester) async { @@ -166,43 +168,39 @@ void main() { await tester.pumpAndSettle(); // async computation; options appear // "User Two" and "User Three" appear, but not "User One" - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: true); - checkUserShown(user3, store, expected: true); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: true); + checkUserShown(user3, expected: true); // Finishing autocomplete updates compose box; causes options to disappear await tester.tap(find.text('User Three')); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) .contains(userMention(user3, users: store)); - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: false); - checkUserShown(user3, store, expected: false); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: false); + checkUserShown(user3, expected: false); // Then a new autocomplete intent brings up options again // TODO(#226): Remove this extra edit when this bug is fixed. await tester.enterText(composeInputFinder, 'hello @user tw'); await tester.enterText(composeInputFinder, 'hello @user two'); await tester.pumpAndSettle(); // async computation; options appear - checkUserShown(user2, store, expected: true); + checkUserShown(user2, expected: true); // Removing autocomplete intent causes options to disappear // TODO(#226): Remove one of these edits when this bug is fixed. await tester.enterText(composeInputFinder, ''); await tester.enterText(composeInputFinder, ' '); - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: false); - checkUserShown(user3, store, expected: false); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: false); + checkUserShown(user3, expected: false); debugNetworkImageHttpClientProvider = null; }); void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { - final richTextFinder = find.textContaining(wildcard.canonicalString, findRichText: true); - final iconFinder = find.byIcon(ZulipIcons.three_person); - final wildcardItemFinder = find.ancestor(of: richTextFinder, - matching: find.ancestor(of: iconFinder, matching: find.byType(Row))); - check(wildcardItemFinder.evaluate().length).equals(expected ? 1 : 0); + check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } testWidgets('wildcard options appear, disappear, and change correctly', (tester) async { @@ -223,8 +221,7 @@ void main() { checkWildcardShown(WildcardMentionOption.stream, expected: false); // Finishing autocomplete updates compose box; causes options to disappear - await tester.tap(find.textContaining(WildcardMentionOption.channel.canonicalString, - findRichText: true)); + await tester.tap(find.text(WildcardMentionOption.channel.canonicalString)); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) .contains(wildcardMention(WildcardMentionOption.channel, store: store)); @@ -236,6 +233,67 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('sublabel', () { + Finder findLabelsForItem({required Finder itemFinder}) { + final itemColumn = find.ancestor( + of: itemFinder, + matching: find.byType(Column), + ).first; + return find.descendant(of: itemColumn, matching: find.byType(Text)); + } + + testWidgets('no sublabel when delivery email is unavailable', (tester) async { + final user = eg.user(fullName: 'User One', deliveryEmail: null); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user '); + await tester.enterText(composeInputFinder, 'hello @user o'); + await tester.pumpAndSettle(); // async computation; options appear + + checkUserShown(user, expected: true); + check(find.text(user.email)).findsNothing(); + check(findLabelsForItem( + itemFinder: find.text(user.fullName))).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('show sublabel when delivery email is available', (tester) async { + final user = eg.user(fullName: 'User One', deliveryEmail: 'email1@email.com'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user '); + await tester.enterText(composeInputFinder, 'hello @user o'); + await tester.pumpAndSettle(); // async computation; options appear + + checkUserShown(user, expected: true); + check(find.text(user.deliveryEmail!)).findsOne(); + check(findLabelsForItem( + itemFinder: find.text(user.fullName))).findsExactly(2); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('show sublabel for wildcard mention items', (tester) async { + final composeInputFinder = await setupToComposeInput(tester, + narrow: const ChannelNarrow(1)); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, '@chann'); + await tester.enterText(composeInputFinder, '@channe'); + await tester.pumpAndSettle(); // async computation; options appear + + checkWildcardShown(WildcardMentionOption.channel, expected: true); + check(find.text('Notify channel')).findsOne(); + check(findLabelsForItem( + itemFinder: find.text('channel'))).findsExactly(2); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); group('emoji', () {