diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a..cec366907d 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000..43d3f6b84f --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..09d0c6a1b0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -809,6 +809,66 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "statusButtonLabelStatusSet": "Status", + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "statusButtonLabelStatusUnset": "Set status", + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "noStatusText": "No status text", + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "setStatusPageTitle": "Set status", + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "statusClearButtonLabel": "Clear", + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "statusSaveButtonLabel": "Save", + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "statusTextHint": "Your status", + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "userStatusBusy": "Busy", + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "userStatusInAMeeting": "In a meeting", + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "userStatusCommuting": "Commuting", + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "userStatusOutSick": "Out sick", + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "userStatusVacationing": "Vacationing", + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "userStatusWorkingRemotely": "Working remotely", + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "userStatusAtTheOffice": "At the office", + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "updateStatusErrorTitle": "Error updating user status. Please try again.", + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, "searchMessagesPageTitle": "Search", "@searchMessagesPageTitle": { "description": "Page title for the 'Search' message view." diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 32d222d620..d0126e7c5a 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -223,6 +223,10 @@ class UserStatusChange { return UserStatus(text: text.or(old.text), emoji: emoji.or(old.emoji)); } + UserStatusChange copyWith({Option? text, Option? emoji}) { + return UserStatusChange(text: text ?? this.text, emoji: emoji ?? this.emoji); + } + factory UserStatusChange.fromJson(Map json) { return UserStatusChange( text: _textFromJson(json), emoji: _emojiFromJson(json)); diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index d07c471e2f..4e47d97576 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; import '../core.dart'; import '../model/model.dart'; @@ -34,6 +35,21 @@ class GetOwnUserResult { Map toJson() => _$GetOwnUserResultToJson(this); } +/// https://zulip.com/api/update-status +Future updateStatus(ApiConnection connection, { + required UserStatusChange change, +}) { + return connection.post('updateStatus', (_) {}, 'users/me/status', { + if (change.text case OptionSome(:var value)) + 'status_text': RawParameter(value ?? ''), + if (change.emoji case OptionSome(:var value)) ...{ + 'emoji_name': RawParameter(value?.emojiName ?? ''), + 'emoji_code': RawParameter(value?.emojiCode ?? ''), + 'reaction_type': RawParameter(value?.reactionType.toJson() ?? ''), + } + }); +} + /// https://zulip.com/api/update-presence /// /// Passes true for `slim_presence` to avoid getting an ancient data format diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 99b52aced1..0c6815caac 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1227,6 +1227,96 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// The status button label in self-user profile page when status is set. + /// + /// In en, this message translates to: + /// **'Status'** + String get statusButtonLabelStatusSet; + + /// The status button label in self-user profile page when status is not set. + /// + /// In en, this message translates to: + /// **'Set status'** + String get statusButtonLabelStatusUnset; + + /// The text part of the status button sub-label in self-user profile page when status text is not set. + /// + /// In en, this message translates to: + /// **'No status text'** + String get noStatusText; + + /// Title for the 'Set status' page. + /// + /// In en, this message translates to: + /// **'Set status'** + String get setStatusPageTitle; + + /// Label for the button that clears the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Clear'** + String get statusClearButtonLabel; + + /// Label for the button that saves the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Save'** + String get statusSaveButtonLabel; + + /// Hint text for the status text input field in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Your status'** + String get statusTextHint; + + /// A suggested user status text, 'Busy'. + /// + /// In en, this message translates to: + /// **'Busy'** + String get userStatusBusy; + + /// A suggested user status text, 'In a meeting'. + /// + /// In en, this message translates to: + /// **'In a meeting'** + String get userStatusInAMeeting; + + /// A suggested user status text, 'Commuting'. + /// + /// In en, this message translates to: + /// **'Commuting'** + String get userStatusCommuting; + + /// A suggested user status text, 'Out sick'. + /// + /// In en, this message translates to: + /// **'Out sick'** + String get userStatusOutSick; + + /// A suggested user status text, 'Vacationing'. + /// + /// In en, this message translates to: + /// **'Vacationing'** + String get userStatusVacationing; + + /// A suggested user status text, 'Working remotely'. + /// + /// In en, this message translates to: + /// **'Working remotely'** + String get userStatusWorkingRemotely; + + /// A suggested user status text, 'At the office'. + /// + /// In en, this message translates to: + /// **'At the office'** + String get userStatusAtTheOffice; + + /// Error title when updating user status failed. + /// + /// In en, this message translates to: + /// **'Error updating user status. Please try again.'** + String get updateStatusErrorTitle; + /// Page title for the 'Search' message view. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 110b0dbe24..bab80954d2 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -664,6 +664,52 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..fb5926d8ad 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -685,6 +685,52 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get userRoleUnknown => 'Unbekannt'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..674d9e8547 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -664,6 +664,52 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..d4e16e0e8c 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -679,6 +679,52 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get userRoleUnknown => 'Sconosciuto'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ff27eaee8b..c0e5b9153b 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -664,6 +664,52 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..62dd0a3a6b 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -664,6 +664,52 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e9cf379b6..a9dfcbff95 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -675,6 +675,52 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get userRoleUnknown => 'Nieznany'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Szukaj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1349d79baa..99a5091266 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -678,6 +678,52 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get userRoleUnknown => 'Неизвестно'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Поиск'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..6844f89b1e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -666,6 +666,52 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznáma'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..d82e44a154 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -690,6 +690,52 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznano'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..2e9526a2de 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -678,6 +678,52 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get userRoleUnknown => 'Невідомо'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b7eaba478f..15b325837e 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -664,6 +664,52 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 0923fdab79..0b4723f0db 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -119,6 +119,8 @@ mixin EmojiStore { Iterable allEmojiCandidates(); + String? getUnicodeEmojiNameByCode(String emojiCode); + // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; @@ -374,6 +376,10 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { return _allEmojiCandidates ??= _generateAllCandidates(); } + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + _serverEmojiData?[emojiCode]?.first; + @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; diff --git a/lib/model/store.dart b/lib/model/store.dart index 9973cbc33e..c29ef23f77 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -658,6 +658,10 @@ class PerAccountStore extends PerAccountStoreBase with @override Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + _emoji.getUnicodeEmojiNameByCode(emojiCode); + EmojiStoreImpl _emoji; //////////////////////////////// diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..841fb90bc3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -738,14 +738,22 @@ class ReactionButtons extends StatelessWidget { : zulipLocalizations.errorReactionAddingFailedTitle); } - void _handleTapMore() { + void _handleTapMore() async { // TODO(design): have emoji picker slide in from right and push // action sheet off to the left // Dismiss current action sheet before opening emoji picker sheet. Navigator.of(pageContext).pop(); - showEmojiPickerSheet(pageContext: pageContext, message: message); + final emoji = await showEmojiPickerSheet(pageContext: pageContext); + if (emoji == null || !pageContext.mounted) return; + unawaited(doAddOrRemoveReaction( + context: pageContext, + doRemoveReaction: false, + messageId: message.id, + emoji: emoji, + errorDialogTitle: + ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle)); } Widget _buildButton({ diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 526c7edfb1..7445370e7a 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -238,8 +238,13 @@ class ComposeAutocomplete extends AutocompleteField with PerAccountStoreAwa /// A user status emoji to be displayed in different parts of the app. /// +/// Use [userId] to show status emoji for that user. +/// Use [emoji] to show the specific emoji passed. +/// +/// Only one of [userId] or [emoji] should be passed. +/// /// Use [padding] to control the padding of status emoji from neighboring /// widgets. /// When there is no status emoji to be shown, the padding will be omitted too. @@ -2060,13 +2065,16 @@ class _PresenceCircleState extends State with PerAccountStoreAwa class UserStatusEmoji extends StatelessWidget { const UserStatusEmoji({ super.key, - required this.userId, + this.userId, + this.emoji, required this.size, this.padding = EdgeInsets.zero, this.neverAnimate = true, - }); + }) : assert((userId == null) != (emoji == null), + 'Only one of the userId or emoji should be provided.'); - final int userId; + final int? userId; + final StatusEmoji? emoji; final double size; final EdgeInsetsGeometry padding; final bool neverAnimate; @@ -2079,7 +2087,8 @@ class UserStatusEmoji extends StatelessWidget { /// Use [position] to tell the emoji span where it is located relative to /// another span, so that it can adjust the necessary padding from it. static InlineSpan asWidgetSpan({ - required int userId, + int? userId, + StatusEmoji? emoji, required double fontSize, required TextScaler textScaler, StatusEmojiPosition position = StatusEmojiPosition.after, @@ -2092,7 +2101,7 @@ class UserStatusEmoji extends StatelessWidget { final size = textScaler.scale(fontSize); return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: UserStatusEmoji(userId: userId, size: size, + child: UserStatusEmoji(userId: userId, emoji: emoji, size: size, padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), neverAnimate: neverAnimate)); } @@ -2100,15 +2109,15 @@ class UserStatusEmoji extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final emoji = store.getUserStatus(userId).emoji; + final effectiveEmoji = emoji ?? store.getUserStatus(userId!).emoji; final placeholder = SizedBox.shrink(); - if (emoji == null) return placeholder; + if (effectiveEmoji == null) return placeholder; final emojiDisplay = store.emojiDisplayFor( - emojiType: emoji.reactionType, - emojiCode: emoji.emojiCode, - emojiName: emoji.emojiName) + emojiType: effectiveEmoji.reactionType, + emojiCode: effectiveEmoji.emojiCode, + emojiName: effectiveEmoji.emojiName) // Web doesn't seem to respect the emojiset user settings for user status. // .resolve(store.userSettings) ; diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..cca31ccc07 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -398,12 +398,11 @@ Future doAddOrRemoveReaction({ } /// Opens a browsable and searchable emoji picker bottom sheet. -void showEmojiPickerSheet({ +Future showEmojiPickerSheet({ required BuildContext pageContext, - required Message message, -}) { +}) async { final store = PerAccountStoreWidget.of(pageContext); - showModalBottomSheet( + return showModalBottomSheet( context: pageContext, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": @@ -423,20 +422,15 @@ void showEmojiPickerSheet({ // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. child: PerAccountStoreWidget( accountId: store.accountId, - child: EmojiPicker(pageContext: pageContext, message: message))); + child: EmojiPicker(pageContext: pageContext))); }); } @visibleForTesting class EmojiPicker extends StatefulWidget { - const EmojiPicker({ - super.key, - required this.pageContext, - required this.message, - }); + const EmojiPicker({super.key, required this.pageContext}); final BuildContext pageContext; - final Message message; @override State createState() => _EmojiPickerState(); @@ -534,8 +528,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat itemCount: _resultsToDisplay.length, itemBuilder: (context, i) => EmojiPickerListEntry( pageContext: widget.pageContext, - emoji: _resultsToDisplay[i].candidate, - message: widget.message)))), + emoji: _resultsToDisplay[i].candidate)))), ]))), ]); } @@ -547,27 +540,15 @@ class EmojiPickerListEntry extends StatelessWidget { super.key, required this.pageContext, required this.emoji, - required this.message, }); final BuildContext pageContext; final EmojiCandidate emoji; - final Message message; static const _emojiSize = 24.0; void _onPressed() { - // Dismiss the enclosing action sheet immediately, - // for swift UI feedback that the user's selection was received. - Navigator.pop(pageContext); - - doAddOrRemoveReaction( - context: pageContext, - doRemoveReaction: false, - messageId: message.id, - emoji: emoji, - errorDialogTitle: - ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle); + Navigator.pop(pageContext, emoji); } @override diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..bc50c3ad39 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -57,125 +57,128 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check_remove". static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_down". + static const IconData chevron_down = IconData(0xf10c, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 26021e108e..3cc767f0c5 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1958,6 +1958,8 @@ class SenderRow extends StatelessWidget { : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 56f098790f..e67b62e382 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -317,6 +317,8 @@ class _SelectedUserChip extends StatelessWidget { fontSize: 16, height: 16 / 16, color: designVariables.labelMenuButton)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), ]))); } } @@ -415,7 +417,11 @@ class _NewDmUserListItem extends StatelessWidget { Avatar(userId: userId, size: 32, borderRadius: 3), SizedBox(width: 8), Expanded( - child: Text(store.userDisplayName(userId), + child: Text.rich( + TextSpan(text: store.userDisplayName(userId), children: [ + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context)), + ]), style: TextStyle( fontSize: 17, height: 19 / 17, diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index b4c610b71f..0b9d7d876c 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -11,11 +11,14 @@ import '../model/narrow.dart'; import 'app_bar.dart'; import 'button.dart'; import 'content.dart'; +import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; import 'remote_settings.dart'; +import 'set_status.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -51,6 +54,7 @@ class ProfilePage extends StatelessWidget { final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); + final userStatus = store.getUserStatus(userId); final displayEmail = store.userDisplayEmail(userId); final items = [ Center( @@ -73,9 +77,21 @@ class ProfilePage extends StatelessWidget { ), // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + if (userId != store.selfUserId) + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), ]), textAlign: TextAlign.center, style: nameStyle), + if (userId != store.selfUserId && userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -83,13 +99,16 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time - if (!store.realmPresenceDisabled && userId == store.selfUserId) ...[ + if (userId == store.selfUserId) ...[ const SizedBox(height: 16), - _InvisibleModeToggle(), + MenuButtonsShape(buttons: [ + _SetStatusButton(), + if (!store.realmPresenceDisabled) + _InvisibleModeToggle(), + ]), const SizedBox(height: 16), ], @@ -119,6 +138,42 @@ class ProfilePage extends StatelessWidget { } } +class _SetStatusButton extends StatelessWidget { + const _SetStatusButton(); + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + final userStatus = store.getUserStatus(store.selfUserId); + + return ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: userStatus == UserStatus.zero + ? localizations.statusButtonLabelStatusUnset + : localizations.statusButtonLabelStatusSet, + subLabel: userStatus == UserStatus.zero ? null : TextSpan(children: [ + UserStatusEmoji.asWidgetSpan( + userId: store.selfUserId, + fontSize: 16, + textScaler: MediaQuery.textScalerOf(context), + position: StatusEmojiPosition.before, + neverAnimate: false, + ), + userStatus.text == null + ? TextSpan(text: localizations.noStatusText, + style: TextStyle(fontStyle: FontStyle.italic)) + : TextSpan(text: userStatus.text), + ]), + icon: ZulipIcons.chevron_right, + onPressed: () { + Navigator.push(context, SetStatusPage.buildRoute( + context: context, oldStatus: userStatus)); + }, + ); + } +} + class _InvisibleModeToggle extends StatelessWidget { const _InvisibleModeToggle(); @@ -127,24 +182,22 @@ class _InvisibleModeToggle extends StatelessWidget { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); - return MenuButtonsShape(buttons: [ - // `value: true` means invisible mode is on, - // i.e., that presenceEnabled is false. - RemoteSettingBuilder( - findValueInStore: (store) => !store.userSettings.presenceEnabled, - sendValueToServer: (value) => updateSettings(store.connection, - newSettings: {UserSettingName.presenceEnabled: !value}), - // TODO(#741) interpret API errors for user - onError: (e, requestedValue) => reportErrorToUserBriefly( - requestedValue - ? zulipLocalizations.turnOnInvisibleModeErrorTitle - : zulipLocalizations.turnOffInvisibleModeErrorTitle), - builder: (value, handleRequestNewValue) => ZulipMenuItemButton( - style: ZulipMenuItemButtonStyle.list, - label: zulipLocalizations.invisibleMode, - onPressed: () => handleRequestNewValue(!value), - toggle: Toggle(value: value, onChanged: handleRequestNewValue))), - ]); + // `value: true` means invisible mode is on, + // i.e., that presenceEnabled is false. + return RemoteSettingBuilder( + findValueInStore: (store) => !store.userSettings.presenceEnabled, + sendValueToServer: (value) => updateSettings(store.connection, + newSettings: {UserSettingName.presenceEnabled: !value}), + // TODO(#741) interpret API errors for user + onError: (e, requestedValue) => reportErrorToUserBriefly( + requestedValue + ? zulipLocalizations.turnOnInvisibleModeErrorTitle + : zulipLocalizations.turnOffInvisibleModeErrorTitle), + builder: (value, handleRequestNewValue) => ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: zulipLocalizations.invisibleMode, + onPressed: () => handleRequestNewValue(!value), + toggle: Toggle(value: value, onChanged: handleRequestNewValue))); } } diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 5526557589..96ecfdbef4 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -104,23 +104,29 @@ class RecentDmConversationsItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); - final String title; + final InlineSpan title; final Widget avatar; int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: - title = store.selfUser.fullName; + title = TextSpan(text: store.selfUser.fullName, children: [ + UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: - title = store.userDisplayName(otherUserId); + title = TextSpan(text: store.userDisplayName(otherUserId), children: [ + UserStatusEmoji.asWidgetSpan(userId: otherUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: otherUserId, size: _avatarSize); userIdForPresence = otherUserId; default: - // TODO(i18n): List formatting, like you can do in JavaScript: - // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) - // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map(store.userDisplayName) - .join(', '); + title = TextSpan( + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + text: narrow.otherRecipientIds.map(store.userDisplayName).join(', ')); avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( child: Icon(color: designVariables.avatarPlaceholderIcon, @@ -148,7 +154,7 @@ class RecentDmConversationsItem extends StatelessWidget { const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( + child: Text.rich( style: TextStyle( fontSize: 17, height: (20 / 17), diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart new file mode 100644 index 0000000000..0d408db1a4 --- /dev/null +++ b/lib/widgets/set_status.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import '../basic.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; +import 'app_bar.dart'; +import 'color.dart'; +import 'content.dart'; +import 'emoji_reaction.dart'; +import 'icons.dart'; +import 'inset_shadow.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +class SetStatusPage extends StatefulWidget { + const SetStatusPage({super.key, required this.oldStatus}); + + final UserStatus oldStatus; + + static AccountRoute buildRoute({ + required BuildContext context, + required UserStatus oldStatus, + }) { + return MaterialAccountWidgetRoute(context: context, + page: SetStatusPage(oldStatus: oldStatus)); + } + + @override + State createState() => _SetStatusPageState(); +} + +class _SetStatusPageState extends State { + late final TextEditingController statusTextController; + late final ValueNotifier statusChange; + + UserStatus get oldStatus => widget.oldStatus; + UserStatus get newStatus => statusChange.value.apply(widget.oldStatus); + + @override + void initState() { + super.initState(); + statusTextController = TextEditingController(text: oldStatus.text) + ..addListener(() { + final trimmedValue = statusTextController.text.trim(); + final text = trimmedValue.isNotEmpty ? trimmedValue : null; + + // Ignore updating [statusChange] for the additional updates with the + // same value from TextField. For example, when a character is deleted. + if (text == newStatus.text) return; + + statusChange.value = statusChange.value.copyWith( + text: toOption(recent: text, old: oldStatus.text), + ); + }); + statusChange = + ValueNotifier(UserStatusChange(text: OptionNone(), emoji: OptionNone())) + ..addListener(() { + final text = statusChange.value.text.or(oldStatus.text) ?? ''; + + // Ignore updating the status text field if it already has the same + // text. It can happen in the following cases: + // 1. Only the emoji is changed. + // 2. The same status is chosen consecutively from the suggested + // statuses list. + // 3. This listener is called as a result of the change in status + // text field. + if (text == statusTextController.text) return; + + statusTextController.text = text; + }); + } + + @override + void dispose() { + statusTextController.dispose(); + statusChange.dispose(); + super.dispose(); + } + + List statusSuggestions(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + + final values = [ + (localizations.userStatusBusy, '1f6e0'), + (localizations.userStatusInAMeeting, '1f4c5'), + (localizations.userStatusCommuting, '1f68c'), + (localizations.userStatusOutSick, '1f912'), + (localizations.userStatusVacationing, '1f334'), + (localizations.userStatusWorkingRemotely, '1f3e0'), + (localizations.userStatusAtTheOffice, '1f3e2'), + ]; + + return [ + for (final (statusText, emojiCode) in values) + if (store.getUnicodeEmojiNameByCode(emojiCode) case final emojiName?) + UserStatus( + text: statusText, + emoji: StatusEmoji( + emojiName: emojiName, + emojiCode: emojiCode, + reactionType: ReactionType.unicodeEmoji)), + ]; + } + + void handleStatusClear() { + statusChange.value = UserStatusChange( + text: toOption(recent: null, old: oldStatus.text), + emoji: toOption(recent: null, old: oldStatus.emoji), + ); + } + + Future handleStatusSave() async { + final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + + Navigator.pop(context); + if (newStatus == oldStatus) return; + + try { + await updateStatus(store.connection, change: statusChange.value); + } catch (e) { + reportErrorToUserBriefly(localizations.updateStatusErrorTitle); + } + } + + void chooseStatusEmoji() async { + final emojiCandidate = await showEmojiPickerSheet(pageContext: context); + if (emojiCandidate == null) return; + + final emoji = StatusEmoji( + emojiName: emojiCandidate.emojiName, + emojiCode: emojiCandidate.emojiCode, + reactionType: emojiCandidate.emojiType, + ); + statusChange.value = statusChange.value.copyWith( + emoji: toOption(recent: emoji, old: oldStatus.emoji) + ); + } + + void chooseStatusSuggestion(UserStatus status) { + statusChange.value = UserStatusChange( + text: toOption(recent: status.text, old: oldStatus.text), + emoji: toOption(recent: status.emoji, old: oldStatus.emoji) + ); + } + + Option toOption({required T recent, required T old}) => + recent == old ? OptionNone() : OptionSome(recent); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final localizations = ZulipLocalizations.of(context); + final suggestions = statusSuggestions(context); + + return Scaffold( + appBar: ZulipAppBar(title: Text(localizations.setStatusPageTitle), + actions: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, _, _) { + return _ActionButton( + label: localizations.statusClearButtonLabel, + icon: ZulipIcons.remove, + onPressed: newStatus == UserStatus.zero + ? null + : handleStatusClear, + ); + }), + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + return _ActionButton( + label: localizations.statusSaveButtonLabel, + icon: ZulipIcons.check, + onPressed: switch ((change.text, change.emoji)) { + (OptionNone(), OptionNone()) => null, + _ => handleStatusSave, + }); + }), + ], + ), + body: Column(children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + // In Figma design, this is 16px, but we compensate for that in + // the icon button below. + start: 8, + top: 8, end: 10, + // In Figma design, this is 4px, be we compensate for that in + // [SingleChildScrollView.padding] below. + bottom: 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + onPressed: chooseStatusEmoji, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric( + vertical: 8, + // In Figma design, there is no horizontal padding, but we + // provide it in order to create a proper tap target size. + horizontal: 8)), + icon: Row(children: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + final emoji = change.emoji.or(oldStatus.emoji); + return emoji == null + ? const Icon(ZulipIcons.smile, size: 24) + : UserStatusEmoji(emoji: emoji, size: 24, neverAnimate: false); + }), + SizedBox(width: 4), + Icon(ZulipIcons.chevron_down, size: 16), + ]), + ), + Expanded(child: TextField( + controller: statusTextController, + minLines: 1, + maxLines: 2, + // The limit on the size of the status text is 60 characters. + // See: https://zulip.com/api/update-status#parameter-status_text + maxLength: 60, + cursorColor: designVariables.textInput, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: 19, height: 24 / 19), + decoration: InputDecoration( + counterText: '', + hintText: localizations.statusTextHint, + hintStyle: TextStyle(color: designVariables.labelSearchPrompt), + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: 8, + // Subtracting 4 pixels to account for the internal + // 4-pixel horizontal padding. + horizontal: 10 - 4, + ), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + )))), + ]), + ), + Expanded(child: InsetShadowBox( + top: 6, bottom: 6, + color: designVariables.mainBackground, + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), // TODO: necessary? + padding: EdgeInsets.symmetric(vertical: 6), + child: Column(children: [ + for (final status in suggestions) + _StatusSuggestionsListEntry( + status: status, + onTap: () => chooseStatusSuggestion(status)), + ])))), + ]), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return TextButton( + onPressed: onPressed, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), + ), + child: Row( + spacing: 4, + children: [ + Icon(icon, size: 24), + Text(label, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600))), + ])); + } +} + +class _StatusSuggestionsListEntry extends StatelessWidget { + const _StatusSuggestionsListEntry({required this.status, required this.onTap}); + + final UserStatus status; + final GestureTapCallback onTap; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: onTap, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith( + (states) => states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent, + ), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 7, horizontal: 16), + child: Row( + spacing: 8, + children: [ + UserStatusEmoji(emoji: status.emoji!, size: 19), + Flexible(child: Text(status.text!, + style: TextStyle(fontSize: 19, height: 24 / 19), + maxLines: 1, + overflow: TextOverflow.ellipsis)), + ])), + ); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..8e70f28e3c 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -213,6 +213,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -309,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -388,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -480,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -559,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -637,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -722,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/test/model/test_store.dart b/test/model/test_store.dart index e77b5fc2a0..18e41bcfb5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -275,8 +275,8 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); } - Future changeUserStatuses(List<(int userId, UserStatusChange change)> changes) async { - for (final (userId, change) in changes) { + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { await changeUserStatus(userId, change); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 573921b663..6d26b9a1e0 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,12 +7,14 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -202,6 +206,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.text('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.text('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 54c714b34d..27da0b0f7f 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/message.dart'; @@ -1772,6 +1773,74 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.text('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.text('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.text('Busy')).findsNothing(); + }); + }); + group('Muted sender', () { void checkMessage(Message message, {required bool expectIsMuted}) { final mutedLabel = 'Muted user'; diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index fc9567d78d..86fe2bfaeb 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; @@ -19,6 +21,8 @@ import '../model/test_store.dart'; import '../test_navigation.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupSheet(WidgetTester tester, { required List users, List? mutedUserIds, @@ -30,7 +34,7 @@ Future setupSheet(WidgetTester tester, { ..onPushed = (route, _) => lastPushedRoute = route; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users); if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); @@ -65,7 +69,8 @@ void main() { } Finder findUserTile(User user) => - find.widgetWithText(InkWell, user.fullName).first; + find.ancestor(of: find.textContaining(user.fullName), + matching: find.byType(InkWell)).first; Finder findUserChip(User user) { final findAvatar = find.byWidgetPredicate((widget) => @@ -120,23 +125,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsOne(); - check(find.text('Charlie Carter')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsOne(); + check(find.textContaining('Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.text('Someone Muted')).findsNothing(); - check(find.text('Muted user')).findsNothing(); + check(find.textContaining('Someone Muted')).findsNothing(); + check(find.textContaining('Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -146,11 +151,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -158,31 +163,31 @@ void main() { await tester.enterText(find.byType(TextField), 'Ali'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); await tester.enterText(find.byType(TextField), 'Anderson'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); await tester.enterText(find.byType(TextField), 'son'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); testWidgets('shows empty state when no users match', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Zebra'); await tester.pump(); - check(find.text('No users found')).findsOne(); - check(find.text('Alice Anderson')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('No users found')).findsOne(); + check(find.textContaining('Alice Anderson')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { @@ -252,7 +257,7 @@ void main() { await tester.tap(findUserTile(eg.selfUser)); await tester.pump(); checkUserSelected(tester, eg.selfUser, true); - check(find.text(eg.selfUser.fullName)).findsExactly(2); + check(find.textContaining(eg.selfUser.fullName)).findsExactly(2); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -264,7 +269,7 @@ void main() { final otherUser = eg.user(fullName: 'Other User'); await setupSheet(tester, users: [eg.selfUser, otherUser]); - check(find.text(eg.selfUser.fullName)).findsOne(); + check(find.textContaining(eg.selfUser.fullName)).findsOne(); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -285,6 +290,75 @@ void main() { }); }); + group('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserChip(user), + matching: find.text('Busy'))).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsOne(); + check(find.descendant(of: findUserChip(user), + matching: find.text('Busy'))).findsNothing(); + }); + }); + group('navigation to DM Narrow', () { Future runAndCheck(WidgetTester tester, { required List users, diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 61a85cb63e..ab243c45bd 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; @@ -96,9 +97,20 @@ void main() { deliveryEmail: 'testuser@example.com'); await setupPage(tester, users: [user], pageUserId: user.userId); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(because: 'find user status emoji', statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(because: 'find user status text', find.text('Busy')).findsOne(); check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); }); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 3eb49f2ca8..b0e94128cc 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; @@ -24,6 +26,8 @@ import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, @@ -34,7 +38,7 @@ Future setupPage(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); for (final user in users) { @@ -176,7 +180,7 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), + matching: find.textContaining(expectedText), )); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); @@ -186,6 +190,16 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.handleEvent(UpdateMessageFlagsAddEvent( @@ -231,6 +245,31 @@ void main() { checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -291,6 +330,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -379,6 +445,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]);