From 2d9a946676bcf164ca0fe7eede6de404e4959f7a Mon Sep 17 00:00:00 2001 From: ashutoshkhadse Date: Thu, 15 Jan 2026 19:25:58 +0530 Subject: [PATCH 1/4] api: Add scheduledEndTime field to UserStatusChange. Add the scheduledEndTime field to UserStatusChange to support the scheduled_end_time API parameter. This allows users to set a time at which their status will be automatically cleared by the server. The field is an Option representing a Unix timestamp in seconds: - OptionNone: no change to the scheduled end time - OptionSome(value): set the scheduled end time to value - OptionSome(null): clear any existing scheduled end time Changes: - Add scheduledEndTime field to UserStatusChange - Add _scheduledEndTimeFromJson parsing method - Update toJson to serialize the field - Update copyWith to include scheduledEndTime --- lib/api/model/model.dart | 41 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 3e23a0fdd3..5e078dc577 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -270,19 +270,42 @@ class UserStatusChange { final Option text; final Option emoji; - const UserStatusChange({required this.text, required this.emoji}); + /// The scheduled end time for the status (Unix timestamp in seconds). + /// + /// If [OptionSome] with a non-null value, the status will be automatically + /// cleared at that time. If [OptionSome] with null, any existing scheduled + /// end time will be cleared. If [OptionNone], no change to the scheduled + /// end time. + final Option scheduledEndTime; + + const UserStatusChange({ + required this.text, + required this.emoji, + this.scheduledEndTime = const OptionNone(), + }); UserStatus apply(UserStatus old) { 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); + UserStatusChange copyWith({ + Option? text, + Option? emoji, + Option? scheduledEndTime, + }) { + return UserStatusChange( + text: text ?? this.text, + emoji: emoji ?? this.emoji, + scheduledEndTime: scheduledEndTime ?? this.scheduledEndTime, + ); } factory UserStatusChange.fromJson(Map json) { return UserStatusChange( - text: _textFromJson(json), emoji: _emojiFromJson(json)); + text: _textFromJson(json), + emoji: _emojiFromJson(json), + scheduledEndTime: _scheduledEndTimeFromJson(json), + ); } static Option _textFromJson(Map json) { @@ -313,6 +336,14 @@ class UserStatusChange { } } + static Option _scheduledEndTimeFromJson(Map json) { + if (!json.containsKey('scheduled_end_time')) { + return OptionNone(); + } + final value = json['scheduled_end_time'] as int?; + return OptionSome(value); + } + Map toJson() { return { if (text case OptionSome(:var value)) @@ -325,6 +356,8 @@ class UserStatusChange { 'emoji_code': value.emojiCode, 'reaction_type': value.reactionType, }, + if (scheduledEndTime case OptionSome(:var value)) + 'scheduled_end_time': value, }; } } From e744f8449838d80e8ff6efab5a6a808be4668dfa Mon Sep 17 00:00:00 2001 From: ashutoshkhadse Date: Thu, 15 Jan 2026 19:26:48 +0530 Subject: [PATCH 2/4] api: Send scheduled_end_time in updateStatus endpoint. Update the updateStatus API call to include the scheduled_end_time parameter when present in UserStatusChange. This allows the server to store when the user's status should be automatically cleared. --- lib/api/route/users.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index 4e47d97576..b24e99f7eb 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -46,10 +46,13 @@ Future updateStatus(ApiConnection connection, { 'emoji_name': RawParameter(value?.emojiName ?? ''), 'emoji_code': RawParameter(value?.emojiCode ?? ''), 'reaction_type': RawParameter(value?.reactionType.toJson() ?? ''), - } + }, + if (change.scheduledEndTime case OptionSome(:var value)) + 'scheduled_end_time': value, }); } + /// https://zulip.com/api/update-presence /// /// Passes true for `slim_presence` to avoid getting an ancient data format From 890629a07dd0acd1613c2ead113528f39c07af41 Mon Sep 17 00:00:00 2001 From: ashutoshkhadse Date: Thu, 15 Jan 2026 19:27:13 +0530 Subject: [PATCH 3/4] l10n: Add status expiration strings. Add localization strings for the status expiration dropdown: - statusExpirationLabel: "Automatically clear status" - statusExpirationNever: "Never" - statusExpirationIn30Minutes: "In 30 minutes" - statusExpirationIn1Hour: "In 1 hour" - statusExpirationTodayAtTime: "Today at {time}" - statusExpirationTomorrow: "Tomorrow" - statusExpirationCustom: "Custom" --- assets/l10n/app_en.arb | 29 +++++++++++++ lib/generated/l10n/zulip_localizations.dart | 42 +++++++++++++++++++ .../l10n/zulip_localizations_ar.dart | 23 ++++++++++ .../l10n/zulip_localizations_de.dart | 23 ++++++++++ .../l10n/zulip_localizations_el.dart | 23 ++++++++++ .../l10n/zulip_localizations_en.dart | 23 ++++++++++ .../l10n/zulip_localizations_es.dart | 23 ++++++++++ .../l10n/zulip_localizations_fr.dart | 23 ++++++++++ .../l10n/zulip_localizations_he.dart | 23 ++++++++++ .../l10n/zulip_localizations_hu.dart | 23 ++++++++++ .../l10n/zulip_localizations_it.dart | 23 ++++++++++ .../l10n/zulip_localizations_ja.dart | 23 ++++++++++ .../l10n/zulip_localizations_nb.dart | 23 ++++++++++ .../l10n/zulip_localizations_pl.dart | 23 ++++++++++ .../l10n/zulip_localizations_ru.dart | 23 ++++++++++ .../l10n/zulip_localizations_sk.dart | 23 ++++++++++ .../l10n/zulip_localizations_sl.dart | 23 ++++++++++ .../l10n/zulip_localizations_uk.dart | 23 ++++++++++ .../l10n/zulip_localizations_vi.dart | 23 ++++++++++ .../l10n/zulip_localizations_zh.dart | 23 ++++++++++ 20 files changed, 485 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 47ed5de895..7ac551bc7f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1153,6 +1153,35 @@ "@updateStatusErrorTitle": { "description": "Error title when updating user status failed." }, + "statusExpirationLabel": "Automatically clear status", + "@statusExpirationLabel": { + "description": "Label for the dropdown to select when status should be automatically cleared." + }, + "statusExpirationNever": "Never", + "@statusExpirationNever": { + "description": "Option to never automatically clear the status." + }, + "statusExpirationIn30Minutes": "In 30 minutes", + "@statusExpirationIn30Minutes": { + "description": "Option to clear status in 30 minutes." + }, + "statusExpirationIn1Hour": "In 1 hour", + "@statusExpirationIn1Hour": { + "description": "Option to clear status in 1 hour." + }, + "statusExpirationTodayAtTime": "Today at {time}", + "@statusExpirationTodayAtTime": { + "description": "Option to clear status today at a specific time.", + "placeholders": { "time": {"type": "String"} } + }, + "statusExpirationTomorrow": "Tomorrow", + "@statusExpirationTomorrow": { + "description": "Option to clear status tomorrow." + }, + "statusExpirationCustom": "Custom", + "@statusExpirationCustom": { + "description": "Option to set a custom time to clear the status." + }, "searchMessagesPageTitle": "Search", "@searchMessagesPageTitle": { "description": "Page title for the 'Search' message view." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index eee647c141..3976de8043 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1691,6 +1691,48 @@ abstract class ZulipLocalizations { /// **'Error updating user status. Please try again.'** String get updateStatusErrorTitle; + /// Label for the dropdown to select when status should be automatically cleared. + /// + /// In en, this message translates to: + /// **'Automatically clear status'** + String get statusExpirationLabel; + + /// Option to never automatically clear the status. + /// + /// In en, this message translates to: + /// **'Never'** + String get statusExpirationNever; + + /// Option to clear status in 30 minutes. + /// + /// In en, this message translates to: + /// **'In 30 minutes'** + String get statusExpirationIn30Minutes; + + /// Option to clear status in 1 hour. + /// + /// In en, this message translates to: + /// **'In 1 hour'** + String get statusExpirationIn1Hour; + + /// Option to clear status today at a specific time. + /// + /// In en, this message translates to: + /// **'Today at {time}'** + String statusExpirationTodayAtTime(String time); + + /// Option to clear status tomorrow. + /// + /// In en, this message translates to: + /// **'Tomorrow'** + String get statusExpirationTomorrow; + + /// Option to set a custom time to clear the status. + /// + /// In en, this message translates to: + /// **'Custom'** + String get statusExpirationCustom; + /// 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 f39720a694..7ee14a2185 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 50b9a0d6c4..524fe919b5 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -995,6 +995,29 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get updateStatusErrorTitle => 'Fehler beim Update des Benutzerstatus. Bitte versuche es nochmal.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Suche'; diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart index bf48405724..00f24d07a5 100644 --- a/lib/generated/l10n/zulip_localizations_el.dart +++ b/lib/generated/l10n/zulip_localizations_el.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 899fc2b3ab..dbcd5dccfa 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart index 021fa4399c..59b8a016d9 100644 --- a/lib/generated/l10n/zulip_localizations_es.dart +++ b/lib/generated/l10n/zulip_localizations_es.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEs extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 9205515b01..807ec310f6 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -986,6 +986,29 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get updateStatusErrorTitle => 'Erreur lors de la mise à jour du statut de l\'utilisateur. Merci de réessayer.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Recherche'; diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart index bf7641e733..705237237c 100644 --- a/lib/generated/l10n/zulip_localizations_he.dart +++ b/lib/generated/l10n/zulip_localizations_he.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsHe extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart index 0fbe7aa3b0..5230b56bcc 100644 --- a/lib/generated/l10n/zulip_localizations_hu.dart +++ b/lib/generated/l10n/zulip_localizations_hu.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsHu extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 05580eafb3..de9f9f1fe0 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -981,6 +981,29 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Titolo della pagina per la visualizzazione del messaggio \"Cerca\".'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ea8da61052..19da508822 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -945,6 +945,29 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get updateStatusErrorTitle => 'ステータスの更新に失敗しました。もう一度お試しください。'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => '検索'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3f04b9c138..318a852156 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cab52a50c4..4e99775b59 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -987,6 +987,29 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Błąd aktualizacji stanu. Spróbuj ponownie.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Szukaj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index fdedf8b02f..3c9f17c118 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -997,6 +997,29 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get updateStatusErrorTitle => 'Ошибка обновления статуса пользователя. Попробуйте ещё раз.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Поиск'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 176cabbe72..572b87d4d2 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -972,6 +972,29 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index beef68a6df..f271391515 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -1006,6 +1006,29 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Napaka pri posodabljanju statusa uporabnika. Poskusite znova.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Iskanje'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index bd92746797..ad530c715a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -988,6 +988,29 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get updateStatusErrorTitle => 'Помилка оновлення статусу користувача. Спробуйте ще раз.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Пошук'; diff --git a/lib/generated/l10n/zulip_localizations_vi.dart b/lib/generated/l10n/zulip_localizations_vi.dart index 082c8a60bc..477480de7e 100644 --- a/lib/generated/l10n/zulip_localizations_vi.dart +++ b/lib/generated/l10n/zulip_localizations_vi.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsVi extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index ebf6ef5bdf..a95d0075ab 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; From cd5aaac0f329ac9aed12d39cbeb7b7fbf7607853 Mon Sep 17 00:00:00 2001 From: ashutoshkhadse Date: Thu, 15 Jan 2026 19:27:51 +0530 Subject: [PATCH 4/4] status: Add "Automatically clear status" dropdown. Add a dropdown to the set status page that allows users to set when their status should automatically expire. The dropdown appears below the status suggestions. Options: - Never (default for custom statuses) - In 30 minutes - In 1 hour - Today at 5:00 PM - Tomorrow - Custom (opens date/time picker) When a preset status is selected, the expiration is automatically set based on the status type: - Busy, In a meeting: In 1 hour - Commuting: In 30 minutes - Out sick: Tomorrow - Vacationing: Never - Working remotely, At the office: Today at 5 PM The time format respects the user's 24-hour time preference. The actual clearing of expired statuses happens server-side via the clear_user_status background job. Fixes: #1944 --- lib/widgets/set_status.dart | 210 +++++++++++++++++++++++++++++- test/widgets/set_status_test.dart | 22 ++-- 2 files changed, 218 insertions(+), 14 deletions(-) diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart index 92ef933a9c..540c6124de 100644 --- a/lib/widgets/set_status.dart +++ b/lib/widgets/set_status.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../api/model/model.dart'; import '../api/route/users.dart'; @@ -18,6 +19,17 @@ import 'text.dart'; import 'theme.dart'; import 'user.dart'; +/// Options for automatically clearing status. +enum StatusExpirationOption { + never, + in30Minutes, + in1Hour, + todayAt5PM, + tomorrow, + custom, +} + + class SetStatusPage extends StatefulWidget { const SetStatusPage({super.key, required this.oldStatus}); @@ -39,6 +51,15 @@ class _SetStatusPageState extends State { late final TextEditingController statusTextController; late final ValueNotifier statusChange; + /// Currently selected expiration option. + StatusExpirationOption _selectedExpiration = StatusExpirationOption.never; + + /// Custom expiration time when "Custom" is selected. + DateTime? _customExpirationTime; + + /// Whether the user has manually changed the expiration. + bool _hasUserChangedExpiration = false; + UserStatus get oldStatus => widget.oldStatus; UserStatus get newStatus => statusChange.value.apply(widget.oldStatus); @@ -83,6 +104,107 @@ class _SetStatusPageState extends State { super.dispose(); } + /// Returns the default expiration option for a given preset status emoji code. + StatusExpirationOption _getDefaultExpiration(String emojiCode) { + return switch (emojiCode) { + '1f6e0' => StatusExpirationOption.in1Hour, // Busy + '1f4c5' => StatusExpirationOption.in1Hour, // In a meeting + '1f68c' => StatusExpirationOption.in30Minutes, // Commuting + '1f912' => StatusExpirationOption.tomorrow, // Out sick + '1f334' => StatusExpirationOption.never, // Vacationing + '1f3e0' => StatusExpirationOption.todayAt5PM, // Working remotely + '1f3e2' => StatusExpirationOption.todayAt5PM, // At the office + _ => StatusExpirationOption.never, // Default/Custom + }; + } + + /// Computes the Unix timestamp (in seconds) for the selected expiration option. + int? _computeExpirationTimestamp() { + final now = DateTime.now(); + return switch (_selectedExpiration) { + StatusExpirationOption.never => null, + StatusExpirationOption.in30Minutes => + now.add(const Duration(minutes: 30)).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.in1Hour => + now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.todayAt5PM => + DateTime(now.year, now.month, now.day, 17, 0).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.tomorrow => + DateTime(now.year, now.month, now.day + 1, 0, 0).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.custom => _customExpirationTime != null + ? _customExpirationTime!.millisecondsSinceEpoch ~/ 1000 + : null, + }; + } + + /// Returns the display name for an expiration option. + String _getExpirationOptionLabel(StatusExpirationOption option, ZulipLocalizations zulipLocalizations) { + final store = PerAccountStoreWidget.of(context); + final use24Hour = store.userSettings.twentyFourHourTime == TwentyFourHourTimeMode.twentyFourHour; + + return switch (option) { + StatusExpirationOption.never => zulipLocalizations.statusExpirationNever, + StatusExpirationOption.in30Minutes => zulipLocalizations.statusExpirationIn30Minutes, + StatusExpirationOption.in1Hour => zulipLocalizations.statusExpirationIn1Hour, + StatusExpirationOption.todayAt5PM => zulipLocalizations.statusExpirationTodayAtTime( + use24Hour ? '17:00' : '5:00 PM'), + StatusExpirationOption.tomorrow => zulipLocalizations.statusExpirationTomorrow, + StatusExpirationOption.custom => zulipLocalizations.statusExpirationCustom, + }; + } + + /// Formats the expiration time for display. + String? _formatExpirationTime() { + final timestamp = _computeExpirationTimestamp(); + if (timestamp == null) return null; + + final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + final store = PerAccountStoreWidget.of(context); + final use24Hour = store.userSettings.twentyFourHourTime == TwentyFourHourTimeMode.twentyFourHour; + + final dateFormat = DateFormat.MMMd(); + final timeFormat = use24Hour ? DateFormat.Hm() : DateFormat('h:mm a'); + + return '${dateFormat.format(dateTime)} at ${timeFormat.format(dateTime)}'; + } + + /// Opens a date and time picker for custom expiration. + Future _pickCustomTime() async { + final now = DateTime.now(); + + final pickedDate = await showDatePicker( + context: context, + initialDate: now.add(const Duration(hours: 1)), + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + ); + if (pickedDate == null || !mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(now.add(const Duration(hours: 1))), + ); + if (pickedTime == null || !mounted) return; + + final customDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + + // Validate the time is in the future + if (customDateTime.isBefore(DateTime.now())) { + return; + } + + setState(() { + _customExpirationTime = customDateTime; + }); + } + + List statusSuggestions(BuildContext context) { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); @@ -118,10 +240,15 @@ class _SetStatusPageState extends State { final zulipLocalizations = ZulipLocalizations.of(context); Navigator.pop(context); - if (newStatus == oldStatus) return; + if (newStatus == oldStatus && _selectedExpiration == StatusExpirationOption.never) return; + + // Include the expiration timestamp in the status change + final changeWithExpiration = statusChange.value.copyWith( + scheduledEndTime: OptionSome(_computeExpirationTimestamp()), + ); try { - await updateStatus(store.connection, change: statusChange.value); + await updateStatus(store.connection, change: changeWithExpiration); } catch (e) { reportErrorToUserBriefly(zulipLocalizations.updateStatusErrorTitle); } @@ -144,6 +271,13 @@ class _SetStatusPageState extends State { statusChange.value = UserStatusChange( text: asChange(status.text, old: oldStatus.text), emoji: asChange(status.emoji, old: oldStatus.emoji)); + + // Auto-set expiration based on the status emoji if user hasn't manually changed it + if (!_hasUserChangedExpiration && status.emoji != null) { + setState(() { + _selectedExpiration = _getDefaultExpiration(status.emoji!.emojiCode); + }); + } } Option asChange(T new_, {required T old}) => @@ -265,6 +399,78 @@ class _SetStatusPageState extends State { StatusSuggestionsListEntry( status: status, onTap: () => chooseStatusSuggestion(status)), + const SizedBox(height: 16), + // Expiration dropdown section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + zulipLocalizations.statusExpirationLabel, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: designVariables.labelMenuButton, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: designVariables.bgSearchInput, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + value: _selectedExpiration, + isExpanded: true, + underline: const SizedBox(), + dropdownColor: designVariables.bgSearchInput, + items: StatusExpirationOption.values.map((option) { + return DropdownMenuItem( + value: option, + child: Text( + _getExpirationOptionLabel(option, zulipLocalizations), + style: TextStyle( + fontSize: 17, + color: designVariables.textInput, + ), + ), + ); + }).toList(), + onChanged: (value) async { + if (value == null) return; + if (value == StatusExpirationOption.custom) { + await _pickCustomTime(); + if (_customExpirationTime != null) { + setState(() { + _selectedExpiration = value; + _hasUserChangedExpiration = true; + }); + } + } else { + setState(() { + _selectedExpiration = value; + _hasUserChangedExpiration = true; + }); + } + }, + ), + ), + if (_selectedExpiration != StatusExpirationOption.never) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _formatExpirationTime() ?? '', + style: TextStyle( + fontSize: 14, + color: designVariables.labelMenuButton, + ), + ), + ), + ], + ), + ), ])))), ])), ); diff --git a/test/widgets/set_status_test.dart b/test/widgets/set_status_test.dart index c1db94096b..c98461cb3d 100644 --- a/test/widgets/set_status_test.dart +++ b/test/widgets/set_status_test.dart @@ -415,12 +415,11 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/users/me/status') - ..bodyFields.deepEquals({ - 'status_text': 'Busy', - 'emoji_name': 'working_on_it', - 'emoji_code': '1f6e0', - 'reaction_type': 'unicode_emoji', - }); + ..bodyFields['status_text'].equals('Busy') + ..bodyFields['emoji_name'].equals('working_on_it') + ..bodyFields['emoji_code'].equals('1f6e0') + ..bodyFields['reaction_type'].equals('unicode_emoji') + ..bodyFields.containsKey('scheduled_end_time'); await testNavObserver.pumpPastTransition(tester); check(find.byType(ProfilePage)).findsOne(); }); @@ -441,12 +440,11 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/users/me/status') - ..bodyFields.deepEquals({ - 'status_text': 'Busy', - 'emoji_name': 'working_on_it', - 'emoji_code': '1f6e0', - 'reaction_type': 'unicode_emoji', - }); + ..bodyFields['status_text'].equals('Busy') + ..bodyFields['emoji_name'].equals('working_on_it') + ..bodyFields['emoji_code'].equals('1f6e0') + ..bodyFields['reaction_type'].equals('unicode_emoji') + ..bodyFields.containsKey('scheduled_end_time'); await testNavObserver.pumpPastTransition(tester); check(find.byType(ProfilePage)).findsOne();