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]);