diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 84e19a9cfa..0ef0c3f461 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/topics.svg b/assets/icons/topics.svg new file mode 100644 index 0000000000..c07afa80b3 --- /dev/null +++ b/assets/icons/topics.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index 0967ef424b..d1def3c893 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -1 +1,22 @@ -{} +{ + "settingsPageTitle": "Einstellungen", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "aboutPageTitle": "Über Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "App-Version", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "chooseAccountPageTitle": "Konto auswählen", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "switchAccountButton": "Konto wechseln", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + } +} diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d11bf43eda..aa01eda043 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -84,6 +84,10 @@ "@actionSheetOptionMarkChannelAsRead": { "description": "Label for marking a channel as read." }, + "actionSheetOptionListOfTopics": "List of topics", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, "actionSheetOptionMuteTopic": "Mute topic", "@actionSheetOptionMuteTopic": { "description": "Label for muting a topic on action sheet." @@ -128,6 +132,10 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, + "actionSheetOptionHideMutedMessage": "Hide muted message again", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." @@ -373,9 +381,13 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "When you restore a message not sent, the content that was previously in the compose box is discarded.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." }, "discardDraftConfirmationDialogConfirmButton": "Discard", "@discardDraftConfirmationDialogConfirmButton": { @@ -397,6 +409,34 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, + "newDmSheetBackButtonLabel": "Back", + "@newDmSheetBackButtonLabel": { + "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." + }, + "newDmSheetNextButtonLabel": "Next", + "@newDmSheetNextButtonLabel": { + "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + }, + "newDmSheetScreenTitle": "New DM", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "New DM", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintEmpty": "Add one or more users", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Add another user…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "newDmSheetNoUsersFound": "No users found", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, "composeBoxDmContentHint": "Message @{user}", "@composeBoxDmContentHint": { "description": "Hint text for content input when sending a message to one other person.", @@ -769,6 +809,10 @@ "@mainMenuMyProfile": { "description": "Label for main-menu button leading to the user's own profile." }, + "topicsButtonLabel": "TOPICS", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "channelFeedButtonTooltip": "Channel feed", "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" @@ -864,6 +908,10 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, + "messageNotSentLabel": "MESSAGE NOT SENT", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "pollVoterNames": "({voterNames})", "@pollVoterNames": { "description": "The list of people who voted for a poll option, wrapped in parentheses.", @@ -911,9 +959,9 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountNotFound": "The account associated with this notification could not be found.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { @@ -935,6 +983,18 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, + "mutedSender": "Muted sender", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Reveal message for muted sender", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Muted user", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 8de9527def..8b2b7b7c74 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -557,10 +557,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Konto związane z tym powiadomieniem już nie istnieje.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" @@ -1049,9 +1045,9 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, "discardDraftConfirmationDialogConfirmButton": "Odrzuć", "@discardDraftConfirmationDialogConfirmButton": { @@ -1072,5 +1068,69 @@ "composeBoxBannerButtonSave": "Zapisz", "@composeBoxBannerButtonSave": { "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "topicsButtonLabel": "WĄTKI", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionListOfTopics": "Lista wątków", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "newDmSheetBackButtonLabel": "Wróć", + "@newDmSheetBackButtonLabel": { + "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." + }, + "newDmSheetNextButtonLabel": "Dalej", + "@newDmSheetNextButtonLabel": { + "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + }, + "newDmSheetScreenTitle": "Nowa DM", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Nowa DM", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintSomeSelected": "Dodaj kolejnego użytkownika…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "mutedSender": "Wyciszony nadawca", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Odsłoń wiadomość od wyciszonego użytkownika", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Wyciszony użytkownik", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "newDmSheetNoUsersFound": "Nie odnaleziono użytkowników", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "actionSheetOptionHideMutedMessage": "Ukryj ponownie wyciszone wiadomości", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "newDmSheetSearchHintEmpty": "Dodaj jednego lub więcej użytkowników", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "messageNotSentLabel": "NIE WYSŁANO WIADOMOŚCI", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + }, + "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 57cf48e3e0..493bebee9a 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -113,7 +113,7 @@ } } }, - "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения", + "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -287,10 +287,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Учетной записи, связанной с этим оповещением, больше нет.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "switchAccountButton": "Сменить учетную запись", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." @@ -393,7 +389,7 @@ "@serverUrlValidationErrorNoUseEmail": { "description": "Error message when URL looks like an email" }, - "errorVideoPlayerFailed": "Не удается воспроизвести видео", + "errorVideoPlayerFailed": "Не удается воспроизвести видео.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -525,7 +521,7 @@ "@successMessageTextCopied": { "description": "Message when content of a message was copied to the user's system clipboard." }, - "errorInvalidResponse": "Получен недопустимый ответ сервера", + "errorInvalidResponse": "Сервер отправил недопустимый ответ.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -1006,5 +1002,135 @@ "experimentalFeatureSettingsWarning": "Эти параметры включают функции, которые все еще находятся в стадии разработки и не готовы. Они могут не работать и вызывать проблемы в других местах приложения.\n\nЦель этих настроек — экспериментирование людьми, работающими над разработкой Zulip.", "@experimentalFeatureSettingsWarning": { "description": "Warning text on settings page for experimental, in-development features" + }, + "errorCouldNotEditMessageTitle": "Сбой редактирования", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerButtonSave": "Сохранить", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Редактирование недоступно", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "ЗАПИСЬ ПРАВОК…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "ПРАВКИ НЕ СОХРАНЕНЫ", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Отказаться от написанного сообщения?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Сбросить", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxBannerButtonCancel": "Отмена", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "actionSheetOptionEditMessage": "Редактировать сообщение", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorMessageEditNotSaved": "Сообщение не сохранено", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "preparingEditMessageContentInput": "Подготовка…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxEnterTopicOrSkipHintText": "Укажите тему (или оставьте “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "composeBoxBannerLabelEditMessage": "Редактирование сообщения", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "Редактирование уже выполняется. Дождитесь завершения.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "discardDraftForEditConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "actionSheetOptionListOfTopics": "Список тем", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "topicsButtonLabel": "ТЕМЫ", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "newDmSheetSearchHintEmpty": "Добавить пользователей", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "mutedSender": "Отключенный отправитель", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Показать сообщение отключенного отправителя", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Отключенный пользователь", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "newDmSheetNoUsersFound": "Никто не найден", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "messageNotSentLabel": "СООБЩЕНИЕ НЕ ОТПРАВЛЕНО", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionHideMutedMessage": "Скрыть отключенное сообщение", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "newDmFabButtonLabel": "Новое ЛС", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + }, + "newDmSheetBackButtonLabel": "Назад", + "@newDmSheetBackButtonLabel": { + "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." + }, + "newDmSheetScreenTitle": "Новое ЛС", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmSheetSearchHintSomeSelected": "Добавить еще…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "newDmSheetNextButtonLabel": "Далее", + "@newDmSheetNextButtonLabel": { + "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + }, + "errorNotificationOpenAccountNotFound": "Учетная запись, связанная с этим уведомлением, не найдена.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" } } diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index 686b1345a6..6323d51064 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -991,10 +991,6 @@ "@experimentalFeatureSettingsWarning": { "description": "Warning text on settings page for experimental, in-development features" }, - "errorNotificationOpenAccountMissing": "Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "errorReactionAddingFailedTitle": "Не вдалося додати реакцію", "@errorReactionAddingFailedTitle": { "description": "Error title when adding a message reaction fails" diff --git a/assets/l10n/app_zh.arb b/assets/l10n/app_zh.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_zh.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb new file mode 100644 index 0000000000..9766804e42 --- /dev/null +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -0,0 +1,3 @@ +{ + "settingsPageTitle": "设置" +} diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb new file mode 100644 index 0000000000..201cee2e56 --- /dev/null +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -0,0 +1,3 @@ +{ + "settingsPageTitle": "設定" +} diff --git a/docs/changelog.md b/docs/changelog.md index d2d50c022b..b806d82aeb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,36 @@ ## Unreleased +## 0.0.30 (2025-05-28) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +We're nearing ready to have this new app replace the legacy +Zulip mobile app, a few weeks from now. + +In addition to all the features in the last beta: +* Muted users are now muted. (#296) +* Improved logic to recover from failed send. (#1441) +* Numerous small improvements to the newest features. + + +### Highlights for developers + +* Resolved in main: #83, #1495, #1456, #1158 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + * #296 via PR #1429 + + ## 0.0.29 (2025-05-19) This is a preview beta, including some experimental changes diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index ecb0eee16a..306596044b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -14,6 +14,7 @@ import 'zulip_localizations_pl.dart'; import 'zulip_localizations_ru.dart'; import 'zulip_localizations_sk.dart'; import 'zulip_localizations_uk.dart'; +import 'zulip_localizations_zh.dart'; // ignore_for_file: type=lint @@ -111,6 +112,17 @@ abstract class ZulipLocalizations { Locale('ru'), Locale('sk'), Locale('uk'), + Locale('zh'), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'CN', + scriptCode: 'Hans', + ), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'TW', + scriptCode: 'Hant', + ), ]; /// Title for About Zulip page. @@ -239,6 +251,12 @@ abstract class ZulipLocalizations { /// **'Mark channel as read'** String get actionSheetOptionMarkChannelAsRead; + /// Label for navigating to a channel's topic-list page. + /// + /// In en, this message translates to: + /// **'List of topics'** + String get actionSheetOptionListOfTopics; + /// Label for muting a topic on action sheet. /// /// In en, this message translates to: @@ -305,6 +323,12 @@ abstract class ZulipLocalizations { /// **'Mark as unread from here'** String get actionSheetOptionMarkAsUnread; + /// Label for hide muted message again button on action sheet. + /// + /// In en, this message translates to: + /// **'Hide muted message again'** + String get actionSheetOptionHideMutedMessage; + /// Label for share button on action sheet. /// /// In en, this message translates to: @@ -625,11 +649,17 @@ abstract class ZulipLocalizations { /// **'Discard the message you’re writing?'** String get discardDraftConfirmationDialogTitle; - /// Message for a confirmation dialog for discarding message text that was typed into the compose box. + /// Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message. /// /// In en, this message translates to: /// **'When you edit a message, the content that was previously in the compose box is discarded.'** - String get discardDraftConfirmationDialogMessage; + String get discardDraftForEditConfirmationDialogMessage; + + /// Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you restore a message not sent, the content that was previously in the compose box is discarded.'** + String get discardDraftForMessageNotSentConfirmationDialogMessage; /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. /// @@ -661,6 +691,48 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; + /// Label for the back button in the new DM sheet, allowing the user to return to the previous screen. + /// + /// In en, this message translates to: + /// **'Back'** + String get newDmSheetBackButtonLabel; + + /// Label for the front button in the new DM sheet, if applicable, for navigation or action. + /// + /// In en, this message translates to: + /// **'Next'** + String get newDmSheetNextButtonLabel; + + /// Title displayed at the top of the new DM screen. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmSheetScreenTitle; + + /// Label for the floating action button (FAB) that opens the new DM sheet. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmFabButtonLabel; + + /// Hint text for the search bar when no users are selected + /// + /// In en, this message translates to: + /// **'Add one or more users'** + String get newDmSheetSearchHintEmpty; + + /// Hint text for the search bar when at least one user is selected + /// + /// In en, this message translates to: + /// **'Add another user…'** + String get newDmSheetSearchHintSomeSelected; + + /// Message shown in the new DM sheet when no users match the search. + /// + /// In en, this message translates to: + /// **'No users found'** + String get newDmSheetNoUsersFound; + /// Hint text for content input when sending a message to one other person. /// /// In en, this message translates to: @@ -1151,6 +1223,12 @@ abstract class ZulipLocalizations { /// **'My profile'** String get mainMenuMyProfile; + /// Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'TOPICS'** + String get topicsButtonLabel; + /// Tooltip for button to navigate to a given channel's feed /// /// In en, this message translates to: @@ -1277,6 +1355,12 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'MESSAGE NOT SENT'** + String get messageNotSentLabel; + /// The list of people who voted for a poll option, wrapped in parentheses. /// /// In en, this message translates to: @@ -1343,11 +1427,11 @@ abstract class ZulipLocalizations { /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification could not be found /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification could not be found.'** + String get errorNotificationOpenAccountNotFound; /// Error title when adding a message reaction fails /// @@ -1379,6 +1463,24 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; + /// Name for a muted user to display in message list. + /// + /// In en, this message translates to: + /// **'Muted sender'** + String get mutedSender; + + /// Label for the button revealing hidden message from a muted sender in message list. + /// + /// In en, this message translates to: + /// **'Reveal message for muted sender'** + String get revealButtonLabel; + + /// Name for a muted user to display all over the app. + /// + /// In en, this message translates to: + /// **'Muted user'** + String get mutedUser; + /// Tooltip for button to scroll to bottom. /// /// In en, this message translates to: @@ -1420,6 +1522,7 @@ class _ZulipLocalizationsDelegate 'ru', 'sk', 'uk', + 'zh', ].contains(locale.languageCode); @override @@ -1427,6 +1530,14 @@ class _ZulipLocalizationsDelegate } ZulipLocalizations lookupZulipLocalizations(Locale locale) { + // Lookup logic when language+script+country codes are specified. + switch (locale.toString()) { + case 'zh_Hans_CN': + return ZulipLocalizationsZhHansCn(); + case 'zh_Hant_TW': + return ZulipLocalizationsZhHantTw(); + } + // Lookup logic when language+country codes are specified. switch (locale.languageCode) { case 'en': @@ -1459,6 +1570,8 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsSk(); case 'uk': return ZulipLocalizationsUk(); + case 'zh': + return ZulipLocalizationsZh(); } throw FlutterError( diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 98dd9a7af6..92b2ce681c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -110,6 +113,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -315,9 +321,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -629,6 +660,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -704,6 +738,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -741,8 +778,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +796,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 08d09bb3c4..08f0dc0443 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -9,10 +9,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations { ZulipLocalizationsDe([String locale = 'de']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Über Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'App-Version'; @override String get aboutPageOpenSourceLicenses => 'Open-source licenses'; @@ -21,13 +21,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get aboutPageTapToView => 'Tap to view'; @override - String get chooseAccountPageTitle => 'Choose account'; + String get chooseAccountPageTitle => 'Konto auswählen'; @override - String get settingsPageTitle => 'Settings'; + String get settingsPageTitle => 'Einstellungen'; @override - String get switchAccountButton => 'Switch account'; + String get switchAccountButton => 'Konto wechseln'; @override String tryAnotherAccountMessage(Object url) { @@ -76,6 +76,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -110,6 +113,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -315,9 +321,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -629,6 +660,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -704,6 +738,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -741,8 +778,8 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +796,15 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 105162429b..dacb23923a 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -110,6 +113,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -315,9 +321,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -629,6 +660,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -704,6 +738,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -741,8 +778,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +796,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 74a2d4bedb..9d7e3ce291 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -110,6 +113,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -315,9 +321,13 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -629,6 +660,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -704,6 +738,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -741,8 +778,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +796,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 02913278b8..92f9e33706 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -110,6 +113,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -315,9 +321,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -629,6 +660,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -704,6 +738,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -741,8 +778,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +796,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 26b4b7e306..75dd013c27 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -78,6 +78,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Oznacz kanał jako przeczytany'; + @override + String get actionSheetOptionListOfTopics => 'Lista wątków'; + @override String get actionSheetOptionMuteTopic => 'Wycisz wątek'; @@ -115,6 +118,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Odtąd oznacz jako nieprzeczytane'; + @override + String get actionSheetOptionHideMutedMessage => + 'Ukryj ponownie wyciszone wiadomości'; + @override String get actionSheetOptionShare => 'Udostępnij'; @@ -322,9 +329,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Czy chcesz przerwać szykowanie wpisu?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @@ -340,6 +351,28 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; + @override + String get newDmSheetBackButtonLabel => 'Wróć'; + + @override + String get newDmSheetNextButtonLabel => 'Dalej'; + + @override + String get newDmSheetScreenTitle => 'Nowa DM'; + + @override + String get newDmFabButtonLabel => 'Nowa DM'; + + @override + String get newDmSheetSearchHintEmpty => + 'Dodaj jednego lub więcej użytkowników'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Dodaj kolejnego użytkownika…'; + + @override + String get newDmSheetNoUsersFound => 'Nie odnaleziono użytkowników'; + @override String composeBoxDmContentHint(String user) { return 'Napisz do @$user'; @@ -638,6 +671,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Mój profil'; + @override + String get topicsButtonLabel => 'WĄTKI'; + @override String get channelFeedButtonTooltip => 'Strumień kanału'; @@ -713,6 +749,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String get messageNotSentLabel => 'NIE WYSŁANO WIADOMOŚCI'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -751,8 +790,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Otwieranie powiadomienia bez powodzenia'; @override - String get errorNotificationOpenAccountMissing => - 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get errorNotificationOpenAccountNotFound => + 'Nie odnaleziono konta powiązanego z tym powiadomieniem.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; @@ -770,6 +809,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noEarlierMessages => 'Brak historii'; + @override + String get mutedSender => 'Wyciszony nadawca'; + + @override + String get revealButtonLabel => 'Odsłoń wiadomość od wyciszonego użytkownika'; + + @override + String get mutedUser => 'Wyciszony użytkownik'; + @override String get scrollToBottomTooltip => 'Przewiń do dołu'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 5d8899290d..3fdd2c59f6 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -78,6 +78,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Отметить канал как прочитанный'; + @override + String get actionSheetOptionListOfTopics => 'Список тем'; + @override String get actionSheetOptionMuteTopic => 'Отключить тему'; @@ -115,6 +118,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Отметить как непрочитанные начиная отсюда'; + @override + String get actionSheetOptionHideMutedMessage => + 'Скрыть отключенное сообщение'; + @override String get actionSheetOptionShare => 'Поделиться'; @@ -128,7 +135,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Редактировать сообщение'; @override String get actionSheetOptionMarkTopicAsRead => @@ -150,7 +157,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не удалось извлечь источник сообщения'; + 'Не удалось извлечь источник сообщения.'; @override String get errorCopyingFailed => 'Сбой копирования'; @@ -201,7 +208,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorMessageNotSent => 'Сообщение не отправлено'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Сообщение не сохранено'; @override String errorLoginCouldNotConnect(String url) { @@ -277,7 +284,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Не удалось снять отметку с сообщения'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; @override String get successLinkCopied => 'Ссылка скопирована'; @@ -297,37 +304,41 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'У вас нет права писать в этом канале.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Редактирование сообщения'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Отмена'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Сохранить'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Редактирование недоступно'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Редактирование уже выполняется. Дождитесь завершения.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ЗАПИСЬ ПРАВОК…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ СОХРАНЕНЫ'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Отказаться от написанного сообщения?'; @override - String get discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + String get discardDraftForEditConfirmationDialogMessage => + 'При изменении сообщения текст из поля для редактирования удаляется.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -341,6 +352,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести сообщение'; + @override + String get newDmSheetBackButtonLabel => 'Назад'; + + @override + String get newDmSheetNextButtonLabel => 'Далее'; + + @override + String get newDmSheetScreenTitle => 'Новое ЛС'; + + @override + String get newDmFabButtonLabel => 'Новое ЛС'; + + @override + String get newDmSheetSearchHintEmpty => 'Добавить пользователей'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Добавить еще…'; + + @override + String get newDmSheetNoUsersFound => 'Никто не найден'; + @override String composeBoxDmContentHint(String user) { return 'Сообщение для @$user'; @@ -358,7 +390,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Подготовка…'; @override String get composeBoxSendTooltip => 'Отправить'; @@ -371,7 +403,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Укажите тему (или оставьте “$defaultTopicName”)'; } @override @@ -514,7 +546,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; + String get errorInvalidResponse => 'Сервер отправил недопустимый ответ.'; @override String get errorNetworkRequestFailed => 'Сбой сетевого запроса'; @@ -535,7 +567,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Не удается воспроизвести видео'; + String get errorVideoPlayerFailed => 'Не удается воспроизвести видео.'; @override String get serverUrlValidationErrorEmpty => 'Пожалуйста, введите URL-адрес.'; @@ -642,6 +674,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Мой профиль'; + @override + String get topicsButtonLabel => 'ТЕМЫ'; + @override String get channelFeedButtonTooltip => 'Лента канала'; @@ -717,6 +752,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageNotSentLabel => 'СООБЩЕНИЕ НЕ ОТПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -755,8 +793,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => - 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => + 'Учетная запись, связанная с этим уведомлением, не найдена.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; @@ -773,6 +811,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noEarlierMessages => 'Предшествующих сообщений нет'; + @override + String get mutedSender => 'Отключенный отправитель'; + + @override + String get revealButtonLabel => 'Показать сообщение отключенного отправителя'; + + @override + String get mutedUser => 'Отключенный пользователь'; + @override String get scrollToBottomTooltip => 'Пролистать вниз'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 3ff534eca5..93103de344 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Stlmiť tému'; @@ -111,6 +114,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Označiť ako neprečítané od tejto správy'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Zdielať'; @@ -315,9 +321,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +343,27 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -631,6 +662,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Môj profil'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -706,6 +740,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -743,8 +780,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; @@ -761,6 +798,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 94fee8825a..9f49e2df4d 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -79,6 +79,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Позначити канал як прочитаний'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Заглушити тему'; @@ -115,6 +118,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Поширити'; @@ -324,9 +330,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -342,6 +352,27 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести повідомлення'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Повідомлення @$user'; @@ -641,6 +672,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Мій профіль'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Стрічка каналу'; @@ -716,6 +750,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -755,8 +792,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; @override - String get errorNotificationOpenAccountMissing => - 'Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; @@ -773,6 +810,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Прокрутити вниз'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart new file mode 100644 index 0000000000..8b3760c36d --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -0,0 +1,832 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class ZulipLocalizationsZh extends ZulipLocalizations { + ZulipLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get aboutPageTitle => 'About Zulip'; + + @override + String get aboutPageAppVersion => 'App version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + + @override + String get aboutPageTapToView => 'Tap to view'; + + @override + String get chooseAccountPageTitle => 'Choose account'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + + @override + String get chooseAccountPageLogOutButton => 'Log out'; + + @override + String get logOutConfirmationDialogTitle => 'Log out?'; + + @override + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Log out'; + + @override + String get chooseAccountButtonAddAnAccount => 'Add an account'; + + @override + String get profileButtonSendDirectMessage => 'Send direct message'; + + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + + @override + String get permissionsNeededTitle => 'Permissions needed'; + + @override + String get permissionsNeededOpenSettings => 'Open settings'; + + @override + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @override + String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Failed to mark topic as unresolved'; + + @override + String get actionSheetOptionCopyMessageText => 'Copy message text'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + + @override + String get actionSheetOptionShare => 'Share'; + + @override + String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + + @override + String get actionSheetOptionStarMessage => 'Star message'; + + @override + String get actionSheetOptionUnstarMessage => 'Unstar message'; + + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + + @override + String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + + @override + String get errorAccountLoggedInTitle => 'Account already logged in'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'The account $email at $server is already in your list of accounts.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; + + @override + String get errorCopyingFailed => 'Copying failed'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Failed to upload file: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num files are', + one: 'File is', + ); + return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Files', + one: 'File', + ); + return '$_temp0 too large'; + } + + @override + String get errorLoginInvalidInputTitle => 'Invalid input'; + + @override + String get errorLoginFailedTitle => 'Login failed'; + + @override + String get errorMessageNotSent => 'Message not sent'; + + @override + String get errorMessageEditNotSaved => 'Message not saved'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Failed to connect to server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Could not connect'; + + @override + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; + + @override + String get errorQuotationFailed => 'Quotation failed'; + + @override + String errorServerMessage(String message) { + return 'The server said:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link could not be opened: $url'; + } + + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + + @override + String get errorSharingFailed => 'Sharing failed'; + + @override + String get errorStarMessageFailedTitle => 'Failed to star message'; + + @override + String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + + @override + String get successLinkCopied => 'Link copied'; + + @override + String get successMessageTextCopied => 'Message text copied'; + + @override + String get successMessageLinkCopied => 'Message link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + + @override + String get errorInvalidResponse => 'The server sent an invalid response.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get topicsButtonLabel => 'TOPICS'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// The translations for Chinese, as used in China, using the Han script (`zh_Hans_CN`). +class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { + ZulipLocalizationsZhHansCn() : super('zh_Hans_CN'); + + @override + String get settingsPageTitle => '设置'; +} + +/// The translations for Chinese, as used in Taiwan, using the Han script (`zh_Hant_TW`). +class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { + ZulipLocalizationsZhHantTw() : super('zh_Hant_TW'); + + @override + String get settingsPageTitle => '設定'; +} diff --git a/lib/model/message.dart b/lib/model/message.dart index 2573cfadc6..719d0704f6 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,11 +1,15 @@ +import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../log.dart'; +import 'binding.dart'; import 'message_list.dart'; import 'store.dart'; @@ -16,6 +20,9 @@ mixin MessageStore { /// All known messages, indexed by [Message.id]. Map get messages; + /// [OutboxMessage]s sent by the user, indexed by [OutboxMessage.localMessageId]. + Map get outboxMessages; + Set get debugMessageListViews; void registerMessageList(MessageListView view); @@ -26,6 +33,15 @@ mixin MessageStore { required String content, }); + /// Remove from [outboxMessages] given the [localMessageId], and return + /// the removed [OutboxMessage]. + /// + /// The outbox message to be taken must exist. + /// + /// The state of the outbox message must be either [OutboxMessageState.failed] + /// or [OutboxMessageState.waitPeriodExpired]. + OutboxMessage takeOutboxMessage(int localMessageId); + /// Reconcile a batch of just-fetched messages with the store, /// mutating the list. /// @@ -78,15 +94,29 @@ class _EditMessageRequestStatus { final String newContent; } -class MessageStoreImpl extends PerAccountStoreBase with MessageStore { - MessageStoreImpl({required super.core}) - // There are no messages in InitialSnapshot, so we don't have - // a use case for initializing MessageStore with nonempty [messages]. - : messages = {}; +class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.core, required String? realmEmptyTopicDisplayName}) + : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, + // There are no messages in InitialSnapshot, so we don't have + // a use case for initializing MessageStore with nonempty [messages]. + messages = {}; + + /// The display name to use for empty topics. + /// + /// This should only be accessed when FL >= 334, since topics cannot + /// be empty otherwise. + // TODO(server-10) simplify this + String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting @override final Map messages; + @override final Set _messageListViews = {}; @override @@ -94,12 +124,16 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override void registerMessageList(MessageListView view) { + assert(!_disposed); final added = _messageListViews.add(view); assert(added); } @override void unregisterMessageList(MessageListView view) { + // TODO: Add `assert(!_disposed);` here once we ensure [PerAccountStore] is + // only disposed after [MessageListView]s with references to it are + // disposed. See [dispose] for details. final removed = _messageListViews.remove(view); assert(removed); } @@ -122,6 +156,9 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + @override + bool _disposed = false; + void dispose() { // Not disposing the [MessageListView]s here, because they are owned by // (i.e., they get [dispose]d by) the [_MessageListState], including in the @@ -137,21 +174,31 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [InheritedNotifier] to rebuild in the next frame) before the owner's // `dispose` or `onNewStore` is called. Discussion: // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 + + assert(!_disposed); + _disposeOutboxMessages(); + _disposed = true; } @override Future sendMessage({required MessageDestination destination, required String content}) { - // TODO implement outbox; see design at - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 - return _apiSendMessage(connection, - destination: destination, - content: content, - readBySender: true, - ); + assert(!_disposed); + if (!debugOutboxEnable) { + return _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true); + } + return _outboxSendMessage( + destination: destination, content: content, + // TODO move [TopicName.processLikeServer] to a substore, eliminating this + // see https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099069276 + realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); } @override void reconcileMessages(List messages) { + assert(!_disposed); // What to do when some of the just-fetched messages are already known? // This is common and normal: in particular it happens when one message list // overlaps another, e.g. a stream and a topic within it. @@ -185,6 +232,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { required String originalRawContent, required String newContent, }) async { + assert(!_disposed); if (_editMessageRequests.containsKey(messageId)) { throw StateError('an edit request is already in progress'); } @@ -202,6 +250,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } catch (e) { // TODO(log) if e is something unexpected + if (_disposed) return; + final status = _editMessageRequests[messageId]; if (status == null) { // The event actually arrived before this request failed @@ -216,6 +266,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); final status = _editMessageRequests.remove(messageId); _notifyMessageListViewsForOneMessage(messageId); if (status == null) { @@ -242,6 +293,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // See [fetchedMessages] for reasoning. messages[event.message.id] = event.message; + _handleMessageEventOutbox(event); + for (final view in _messageListViews) { view.handleMessageEvent(event); } @@ -435,4 +488,414 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [Poll] is responsible for notifying the affected listeners. poll.handleSubmessageEvent(event); } + + /// In debug mode, controls whether outbox messages should be created when + /// [sendMessage] is called. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugOutboxEnable { + bool result = true; + assert(() { + result = _debugOutboxEnable; + return true; + }()); + return result; + } + static bool _debugOutboxEnable = true; + static set debugOutboxEnable(bool value) { + assert(() { + _debugOutboxEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugOutboxEnable = true; + } +} + +/// The duration an outbox message stays hidden to the user. +/// +/// See [OutboxMessageState.waiting]. +const kLocalEchoDebounceDuration = Duration(milliseconds: 500); // TODO(#1441) find the right value for this + +/// The duration before an outbox message can be restored for resending, since +/// its creation. +/// +/// See [OutboxMessageState.waitPeriodExpired]. +const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right value for this + +/// States of an [OutboxMessage] since its creation from a +/// [MessageStore.sendMessage] call and before its eventual deletion. +/// +/// ``` +/// Got an [ApiRequestException]. +/// ┌──────┬────────────────────────────┬──────────► failed +/// │ │ │ │ +/// │ │ [sendMessage] │ │ +/// (create) │ │ request succeeds. │ │ +/// └► hidden waiting ◄─────────────── waitPeriodExpired ──┴─────► (delete) +/// │ ▲ │ ▲ User restores +/// └──────┘ └─────────────────────┘ the draft. +/// Debounce [sendMessage] request +/// timed out. not finished when +/// wait period timed out. +/// +/// Event received. +/// (any state) ─────────────────► (delete) +/// ``` +/// +/// During its lifecycle, it is guaranteed that the outbox message is deleted +/// as soon a message event with a matching [MessageEvent.localMessageId] +/// arrives. +enum OutboxMessageState { + /// The [sendMessage] HTTP request has started but the resulting + /// [MessageEvent] hasn't arrived, and nor has the request failed. In this + /// state, the outbox message is hidden to the user. + /// + /// This is the initial state when an [OutboxMessage] is created. + hidden, + + /// The [sendMessage] HTTP request has started but hasn't finished, and the + /// outbox message is shown to the user. + /// + /// This state can be reached after staying in [hidden] for + /// [kLocalEchoDebounceDuration], or when the request succeeds after the + /// outbox message reaches [OutboxMessageState.waitPeriodExpired]. + waiting, + + /// The [sendMessage] HTTP request did not finish in time and the user is + /// invited to retry it. + /// + /// This state can be reached when the request has not finished + /// [kSendMessageOfferRestoreWaitPeriod] since the outbox message's creation. + waitPeriodExpired, + + /// The message could not be delivered, and the user is invited to retry it. + /// + /// This state can be reached when we got an [ApiRequestException] from the + /// [sendMessage] HTTP request. + failed, +} + +/// An outstanding request to send a message, aka an outbox-message. +/// +/// This will be shown in the UI in the message list, as a placeholder +/// for the actual [Message] the request is anticipated to produce. +/// +/// A request remains "outstanding" even after the [sendMessage] HTTP request +/// completes, whether with success or failure. +/// The outbox-message persists until either the corresponding [MessageEvent] +/// arrives to replace it, or the user discards it (perhaps to try again). +/// For details, see the state diagram at [OutboxMessageState], +/// and [MessageStore.takeOutboxMessage]. +sealed class OutboxMessage extends MessageBase { + OutboxMessage({ + required this.localMessageId, + required int selfUserId, + required super.timestamp, + required this.contentMarkdown, + }) : _state = OutboxMessageState.hidden, + super(senderId: selfUserId); + + // TODO(dart): This has to be a plain static method, because factories/constructors + // do not support type parameters: https://github.com/dart-lang/language/issues/647 + static OutboxMessage fromConversation(Conversation conversation, { + required int localMessageId, + required int selfUserId, + required int timestamp, + required String contentMarkdown, + }) { + return switch (conversation) { + StreamConversation() => StreamOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + DmConversation() => DmOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + }; + } + + /// As in [MessageEvent.localMessageId]. + /// + /// This uniquely identifies this outbox message's corresponding message object + /// in events from the same event queue. + /// + /// See also: + /// * [MessageStoreImpl.sendMessage], where this ID is assigned. + final int localMessageId; + + @override + int? get id => null; + + final String contentMarkdown; + + OutboxMessageState get state => _state; + OutboxMessageState _state; + + /// Whether the [OutboxMessage] is hidden to [MessageListView] or not. + bool get hidden => state == OutboxMessageState.hidden; +} + +class StreamOutboxMessage extends OutboxMessage { + StreamOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }); + + @override + final StreamConversation conversation; +} + +class DmOutboxMessage extends OutboxMessage { + DmOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }) : assert(conversation.allRecipientIds.contains(selfUserId)); + + @override + final DmConversation conversation; +} + +/// Manages the outbox messages portion of [MessageStore]. +mixin _OutboxMessageStore on PerAccountStoreBase { + late final UnmodifiableMapView outboxMessages = + UnmodifiableMapView(_outboxMessages); + final Map _outboxMessages = {}; + + /// A map of timers to show outbox messages after a delay, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request fails within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageDebounceTimers = {}; + + /// A map of timers to update outbox messages state to + /// [OutboxMessageState.waitPeriodExpired] if the [sendMessage] + /// request did not complete in time, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request completes within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageWaitPeriodTimers = {}; + + /// A fresh ID to use for [OutboxMessage.localMessageId], + /// unique within this instance. + int _nextLocalMessageId = 1; + + /// As in [MessageStoreImpl._messageListViews]. + Set get _messageListViews; + + /// As in [MessageStoreImpl._disposed]. + bool get _disposed; + + /// Update the state of the [OutboxMessage] with the given [localMessageId], + /// and notify listeners if necessary. + /// + /// The outbox message with [localMessageId] must exist. + void _updateOutboxMessage(int localMessageId, { + required OutboxMessageState newState, + }) { + assert(!_disposed); + final outboxMessage = outboxMessages[localMessageId]; + if (outboxMessage == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + final oldState = outboxMessage.state; + // See [OutboxMessageState] for valid state transitions. + final isStateTransitionValid = switch (newState) { + OutboxMessageState.hidden => false, + OutboxMessageState.waiting => + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waitPeriodExpired, + OutboxMessageState.waitPeriodExpired => + oldState == OutboxMessageState.waiting, + OutboxMessageState.failed => + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waiting + || oldState == OutboxMessageState.waitPeriodExpired, + }; + if (!isStateTransitionValid) { + throw StateError('Unexpected state transition: $oldState -> $newState'); + } + + outboxMessage._state = newState; + for (final view in _messageListViews) { + if (oldState == OutboxMessageState.hidden) { + view.addOutboxMessage(outboxMessage); + } else { + view.notifyListenersIfOutboxMessagePresent(localMessageId); + } + } + } + + /// Send a message and create an entry of [OutboxMessage]. + Future _outboxSendMessage({ + required MessageDestination destination, + required String content, + required String? realmEmptyTopicDisplayName, + }) async { + assert(!_disposed); + final localMessageId = _nextLocalMessageId++; + assert(!outboxMessages.containsKey(localMessageId)); + + final conversation = switch (destination) { + StreamDestination(:final streamId, :final topic) => + StreamConversation( + streamId, + _processTopicLikeServer( + topic, realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), + displayRecipient: null), + DmDestination(:final userIds) => DmConversation(allRecipientIds: userIds), + }; + + _outboxMessages[localMessageId] = OutboxMessage.fromConversation( + conversation, + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000, + contentMarkdown: content); + + _outboxMessageDebounceTimers[localMessageId] = Timer( + kLocalEchoDebounceDuration, + () => _handleOutboxDebounce(localMessageId)); + + _outboxMessageWaitPeriodTimers[localMessageId] = Timer( + kSendMessageOfferRestoreWaitPeriod, + () => _handleOutboxWaitPeriodExpired(localMessageId)); + + try { + await _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true, + queueId: queueId, + localId: localMessageId.toString()); + } catch (e) { + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; the failure is probably due to + // networking issues. Don't rethrow; the send succeeded + // (we got the event) so we don't want to show an error dialog. + return; + } + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.failed); + rethrow; + } + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; nothing to do. + return; + } + // The send request succeeded, so the message was definitely sent. + // Cancel the timer that would have had us start presuming that the + // send might have failed. + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (_outboxMessages[localMessageId]!.state + == OutboxMessageState.waitPeriodExpired) { + // The user was offered to restore the message since the request did not + // complete for a while. Since the request was successful, we expect the + // message event to arrive eventually. Stop inviting the the user to + // retry, to avoid double-sends. + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } + } + + TopicName _processTopicLikeServer(TopicName topic, { + required String? realmEmptyTopicDisplayName, + }) { + return topic.processLikeServer( + // Processing this just once on creating the outbox message + // allows an uncommon bug, because either of these values can change. + // During the outbox message's life, a topic processed from + // "(no topic)" could become stale/wrong when zulipFeatureLevel + // changes; a topic processed from "general chat" could become + // stale/wrong when realmEmptyTopicDisplayName changes. + // + // Shrug. The same effect is caused by an unavoidable race: + // an admin could change the name of "general chat" + // (i.e. the value of realmEmptyTopicDisplayName) + // concurrently with the user making the send request, + // so that the setting in effect by the time the request arrives + // is different from the setting the client last heard about. + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName); + } + + void _handleOutboxDebounce(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + _outboxMessageDebounceTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } + + void _handleOutboxWaitPeriodExpired(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + assert(!_outboxMessageDebounceTimers.containsKey(localMessageId), + 'The debounce timer should have been removed before the wait period timer expires.'); + _outboxMessageWaitPeriodTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waitPeriodExpired); + } + + OutboxMessage takeOutboxMessage(int localMessageId) { + assert(!_disposed); + final removed = _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (removed == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + if (removed.state != OutboxMessageState.failed + && removed.state != OutboxMessageState.waitPeriodExpired + ) { + throw StateError('Unexpected state when restoring draft: ${removed.state}'); + } + for (final view in _messageListViews) { + view.removeOutboxMessage(removed); + } + return removed; + } + + void _handleMessageEventOutbox(MessageEvent event) { + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // The outbox message can be missing if the user removes it (to be + // implemented in #1441) before the event arrives. + // Nothing to do in that case. + _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + } + + /// Cancel [_OutboxMessageStore]'s timers. + void _disposeOutboxMessages() { + assert(!_disposed); + for (final timer in _outboxMessageDebounceTimers.values) { + timer.cancel(); + } + for (final timer in _outboxMessageWaitPeriodTimers.values) { + timer.cancel(); + } + } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f2a45b78aa..8022ba8a7d 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,6 +10,7 @@ import '../api/route/messages.dart'; import 'algorithms.dart'; import 'channel.dart'; import 'content.dart'; +import 'message.dart'; import 'narrow.dart'; import 'store.dart'; @@ -321,24 +322,31 @@ mixin _MessageSequence { _reprocessAll(); } - /// Append to [items] based on the index-th message and its content. + /// Append to [items] based on [message] and [prevMessage]. /// - /// The previous messages in the list must already have been processed. - /// This message must already have been parsed and reflected in [contents]. - void _processMessage(int index) { - // This will get more complicated to handle the ways that messages interact - // with the display of neighboring messages: sender headings #175 - // and date separators #173. - final message = messages[index]; - final content = contents[index]; - bool canShareSender; - if (index == 0 || !haveSameRecipient(messages[index - 1], message)) { + /// This appends a recipient header or a date separator to [items], + /// depending on how [prevMessage] relates to [message], + /// and then the result of [buildItem], updating [middleItem] if desired. + /// + /// See [middleItem] to determine the value of [shouldSetMiddleItem]. + /// + /// [prevMessage] should be the message that visually appears before [message]. + /// + /// The caller must ensure that [prevMessage] and all messages before it + /// have been processed. + void _addItemsForMessage(MessageBase message, { + required bool shouldSetMiddleItem, + required MessageBase? prevMessage, + required MessageListMessageBaseItem Function(bool canShareSender) buildItem, + }) { + final bool canShareSender; + if (prevMessage == null || !haveSameRecipient(prevMessage, message)) { items.add(MessageListRecipientHeaderItem(message)); canShareSender = false; } else { - assert(items.last is MessageListMessageItem); - final prevMessageItem = items.last as MessageListMessageItem; - assert(identical(prevMessageItem.message, messages[index - 1])); + assert(items.last is MessageListMessageBaseItem); + final prevMessageItem = items.last as MessageListMessageBaseItem; + assert(identical(prevMessageItem.message, prevMessage)); assert(prevMessageItem.isLastInBlock); prevMessageItem.isLastInBlock = false; @@ -346,12 +354,34 @@ mixin _MessageSequence { items.add(MessageListDateSeparatorItem(message)); canShareSender = false; } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + canShareSender = prevMessageItem.message.senderId == message.senderId; } } - if (index == middleMessage) middleItem = items.length; - items.add(MessageListMessageItem(message, content, - showSender: !canShareSender, isLastInBlock: true)); + final item = buildItem(canShareSender); + assert(identical(item.message, message)); + assert(item.showSender == !canShareSender); + assert(item.isLastInBlock); + if (shouldSetMiddleItem) { + assert(item is MessageListMessageItem); + middleItem = items.length; + } + items.add(item); + } + + /// Append to [items] based on the index-th message and its content. + /// + /// The previous messages in the list must already have been processed. + /// This message must already have been parsed and reflected in [contents]. + void _processMessage(int index) { + final prevMessage = index == 0 ? null : messages[index - 1]; + final message = messages[index]; + final content = contents[index]; + + _addItemsForMessage(message, + shouldSetMiddleItem: index == middleMessage, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListMessageItem( + message, content, showSender: !canShareSender, isLastInBlock: true)); } /// Recompute [items] from scratch, based on [messages], [contents], and flags. @@ -616,6 +646,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Add [outboxMessage] if it belongs to the view. + void addOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + + /// Remove the [outboxMessage] from the view. + /// + /// This is a no-op if the message is not found. + /// + /// This should only be called from [MessageStore.takeOutboxMessage]. + void removeOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { case VisibilityEffect.none: @@ -777,6 +821,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + // TODO(#1441) implement this + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo from scratch any computations we can, such as parsing diff --git a/lib/model/store.dart b/lib/model/store.dart index 240e3ab4e4..18a09e32ce 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -501,7 +501,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), ), channels: channels, - messages: MessageStoreImpl(core: core), + messages: MessageStoreImpl(core: core, + realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), unreads: Unreads( initial: initialSnapshot.unreadMsgs, core: core, @@ -745,6 +746,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Map get messages => _messages.messages; @override + Map get outboxMessages => _messages.outboxMessages; + @override void registerMessageList(MessageListView view) => _messages.registerMessageList(view); @override @@ -756,6 +759,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return _messages.sendMessage(destination: destination, content: content); } @override + OutboxMessage takeOutboxMessage(int localMessageId) => + _messages.takeOutboxMessage(localMessageId); + @override void reconcileMessages(List messages) { _messages.reconcileMessages(messages); // TODO(#649) notify [unreads] of the just-fetched messages diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 00a5f6fda5..081a2cf633 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -507,7 +507,7 @@ class NotificationDisplayManager { final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); + message: zulipLocalizations.errorNotificationOpenAccountNotFound); return null; } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 04e535e65a..6bd4e1024a 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,6 +29,7 @@ import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( BuildContext context, { @@ -175,24 +176,43 @@ void showChannelActionSheet(BuildContext context, { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); - final optionButtons = []; + final optionButtons = [ + TopicListButton(pageContext: pageContext, channelId: channelId), + ]; + final unreadCount = store.unreads.countInChannelNarrow(channelId); if (unreadCount > 0) { optionButtons.add( MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId)); } - if (optionButtons.isEmpty) { - // TODO(a11y): This case makes a no-op gesture handler; as a consequence, - // we're presenting some UI (to people who use screen-reader software) as - // though it offers a gesture interaction that it doesn't meaningfully - // offer, which is confusing. The solution here is probably to remove this - // is-empty case by having at least one button that's always present, - // such as "copy link to channel". - return; - } + _showActionSheet(pageContext, optionButtons: optionButtons); } +class TopicListButton extends ActionSheetMenuItemButton { + const TopicListButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.topics; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionListOfTopics; + } + + @override + void onPressed() { + Navigator.push(pageContext, + TopicListPage.buildRoute(context: pageContext, streamId: channelId)); + } +} + class MarkChannelAsReadButton extends ActionSheetMenuItemButton { const MarkChannelAsReadButton({ super.key, diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d629e69029..2f47f05f44 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1849,11 +1849,13 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override void startEditInteraction(int messageId) async { - if (await _abortBecauseContentInputNotEmpty()) return; - if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForEditConfirmationDialogMessage); + if (abort || !mounted) return; final store = PerAccountStoreWidget.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); switch (store.getEditMessageErrorStatus(messageId)) { case null: @@ -1878,12 +1880,14 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM /// If there's text in the compose box, give a confirmation dialog /// asking if it can be discarded and await the result. - Future _abortBecauseContentInputNotEmpty() async { + Future _abortBecauseContentInputNotEmpty({ + required String dialogMessage, + }) async { final zulipLocalizations = ZulipLocalizations.of(context); if (controller.content.textNormalized.isNotEmpty) { final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.discardDraftConfirmationDialogTitle, - message: zulipLocalizations.discardDraftConfirmationDialogMessage, + message: dialogMessage, // TODO(#1032) "destructive" style for action button actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); if (await dialog.result != true) return true; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 40b510305d..62801ab867 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -26,6 +26,7 @@ import 'poll.dart'; import 'scrolling.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; /// A central place for styles for Zulip content (rendered Zulip Markdown). /// @@ -988,7 +989,7 @@ class WebsitePreview extends StatelessWidget { // TODO(#488) use different color for non-message contexts // TODO(#647) use different color for highlighted messages // TODO(#681) use different color for DM messages - color: MessageListTheme.of(context).bgMessageRegular, + color: DesignVariables.of(context).bgMessageRegular, child: ClipRect( child: ConstrainedBox( constraints: BoxConstraints(maxHeight: 80), diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index bab7b152de..be088afd48 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -144,11 +144,14 @@ abstract final class ZulipIcons { /// The Zulip custom icon "topic". static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "topics". + static const IconData topics = IconData(0xf129, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dcd063a62a..e25445e514 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -24,11 +24,11 @@ import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { static final light = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), labelTime: const HSLColor.fromAHSL(0.49, 0, 0, 0).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), @@ -46,7 +46,6 @@ class MessageListTheme extends ThemeExtension { ); static final dark = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), labelTime: const HSLColor.fromAHSL(0.5, 0, 0, 1).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), @@ -63,7 +62,6 @@ class MessageListTheme extends ThemeExtension { ); MessageListTheme._({ - required this.bgMessageRegular, required this.dmRecipientHeaderBg, required this.labelTime, required this.senderBotIcon, @@ -82,7 +80,6 @@ class MessageListTheme extends ThemeExtension { return extension!; } - final Color bgMessageRegular; final Color dmRecipientHeaderBg; final Color labelTime; final Color senderBotIcon; @@ -92,7 +89,6 @@ class MessageListTheme extends ThemeExtension { @override MessageListTheme copyWith({ - Color? bgMessageRegular, Color? dmRecipientHeaderBg, Color? labelTime, Color? senderBotIcon, @@ -101,7 +97,6 @@ class MessageListTheme extends ThemeExtension { Color? unreadMarkerGap, }) { return MessageListTheme._( - bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg, labelTime: labelTime ?? this.labelTime, senderBotIcon: senderBotIcon ?? this.senderBotIcon, @@ -117,7 +112,6 @@ class MessageListTheme extends ThemeExtension { return this; } return MessageListTheme._( - bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, labelTime: Color.lerp(labelTime, other.labelTime, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, @@ -227,14 +221,23 @@ class _MessageListPageState extends State implements MessageLis removeAppBarBottomBorder = true; } - List? actions; - if (narrow case TopicNarrow(:final streamId)) { - (actions ??= []).add(IconButton( - icon: const Icon(ZulipIcons.message_feed), - tooltip: zulipLocalizations.channelFeedButtonTooltip, - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(streamId))))); + List actions = []; + switch (narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case DmNarrow(): + break; + case ChannelNarrow(:final streamId): + actions.add(_TopicListButton(streamId: streamId)); + case TopicNarrow(:final streamId): + actions.add(IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId))))); + actions.add(_TopicListButton(streamId: streamId)); } // Insert a PageRoot here, to provide a context that can be used for @@ -284,6 +287,35 @@ class _MessageListPageState extends State implements MessageLis } } +class _TopicListButton extends StatelessWidget { + const _TopicListButton({required this.streamId}); + + final int streamId; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + return GestureDetector( + onTap: () { + Navigator.of(context).push(TopicListPage.buildRoute( + context: context, streamId: streamId)); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(12, 8, 12, 8), + child: Center(child: Text(zulipLocalizations.topicsButtonLabel, + style: TextStyle( + color: designVariables.icon, + fontSize: 18, + height: 19 / 18, + // This is equivalent to css `all-small-caps`, see: + // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context, wght: 600)))))); + } +} + class MessageListAppBarTitle extends StatelessWidget { const MessageListAppBarTitle({ super.key, @@ -297,9 +329,18 @@ class MessageListAppBarTitle extends StatelessWidget { Widget _buildStreamRow(BuildContext context, { ZulipStream? stream, }) { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + // A null [Icon.icon] makes a blank space. - final icon = stream != null ? iconDataForStream(stream) : null; + IconData? icon; + Color? iconColor; + if (stream != null) { + icon = iconDataForStream(stream); + iconColor = colorSwatchFor(context, store.subscriptions[stream.streamId]) + .iconOnBarBackground; + } + return Row( mainAxisSize: MainAxisSize.min, // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. @@ -307,7 +348,7 @@ class MessageListAppBarTitle extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(size: 16, icon), + Icon(size: 16, color: iconColor, icon), const SizedBox(width: 4), Flexible(child: Text( stream?.name ?? zulipLocalizations.unknownChannelName)), @@ -718,7 +759,6 @@ class _MessageListState extends State with PerAccountStoreAwareStat return MessageItem( key: ValueKey(data.message.id), header: header, - trailingWhitespace: 11, item: data); } } @@ -981,13 +1021,12 @@ class DateSeparator extends StatelessWidget { // to align with the vertically centered divider lines. const textBottomPadding = 2.0; - final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); final line = BorderSide(width: 0, color: designVariables.foreground); // TODO(#681) use different color for DM messages - return ColoredBox(color: messageListTheme.bgMessageRegular, + return ColoredBox(color: designVariables.bgMessageRegular, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), child: Row(children: [ @@ -1017,25 +1056,25 @@ class MessageItem extends StatelessWidget { super.key, required this.item, required this.header, - this.trailingWhitespace, }); final MessageListMessageBaseItem item; final Widget header; - final double? trailingWhitespace; @override Widget build(BuildContext context) { - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); final item = this.item; Widget child = ColoredBox( - color: messageListTheme.bgMessageRegular, + color: designVariables.bgMessageRegular, child: Column(children: [ switch (item) { MessageListMessageItem() => MessageWithPossibleSender(item: item), }, - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + // TODO refine this padding; discussion: + // https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2106526985 + if (item.isLastInBlock) const SizedBox(height: 11), ])); if (item case MessageListMessageItem(:final message)) { child = _UnreadMarker( @@ -1377,7 +1416,7 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); class _SenderRow extends StatelessWidget { const _SenderRow({required this.message, required this.showTimestamp}); - final Message message; + final MessageBase message; final bool showTimestamp; @override @@ -1407,7 +1446,9 @@ class _SenderRow extends StatelessWidget { userId: message.senderId), const SizedBox(width: 8), Flexible( - child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName` + child: Text(message is Message + ? store.senderDisplayName(message as Message) + : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), @@ -1496,7 +1537,7 @@ class MessageWithPossibleSender extends StatelessWidget { behavior: HitTestBehavior.translucent, onLongPress: () => showMessageActionSheet(context: context, message: message), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ if (item.showSender) _SenderRow(message: message, showTimestamp: true), @@ -1514,14 +1555,18 @@ class MessageWithPossibleSender extends StatelessWidget { if (editMessageErrorStatus != null) _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) else if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)))) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) ])), SizedBox(width: 16, child: star), @@ -1552,30 +1597,34 @@ class _EditMessageStatusRow extends StatelessWidget { return switch (status) { // TODO parse markdown and show new content as local echo? - false => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 1.5, - children: [ - Text( + false => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within bottom outer padding: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ])), + true => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreEditMessageGestureDetector( + messageId: messageId, + child: Text( style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntInfo), + .copyWith(color: designVariables.btnLabelAttLowIntDanger), textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditLabel), - // TODO instead place within bottom outer padding: - // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 - LinearProgressIndicator( - minHeight: 2, - color: designVariables.foreground.withValues(alpha: 0.5), - backgroundColor: designVariables.foreground.withValues(alpha: 0.2), - ), - ]), - true => _RestoreEditMessageGestureDetector( - messageId: messageId, - child: Text( - style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntDanger), - textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditFailedLabel)), + zulipLocalizations.savingMessageEditFailedLabel))), }; } } @@ -1595,6 +1644,7 @@ class _RestoreEditMessageGestureDetector extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: () { final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-edit-message from any message-list page if (composeBoxState == null) return; composeBoxState.startEditInteraction(messageId); }, diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 276e308b2b..492f82c88a 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -137,6 +137,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), bgMenuButtonSelected: Colors.white, + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), @@ -197,6 +198,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), @@ -265,6 +267,7 @@ class DesignVariables extends ThemeExtension { required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, @@ -334,6 +337,7 @@ class DesignVariables extends ThemeExtension { final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; @@ -398,6 +402,7 @@ class DesignVariables extends ThemeExtension { Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, @@ -457,6 +462,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, + bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, @@ -523,6 +529,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, + bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart new file mode 100644 index 0000000000..61e16df6ec --- /dev/null +++ b/lib/widgets/topic_list.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'action_sheet.dart'; +import 'app_bar.dart'; +import 'color.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +class TopicListPage extends StatelessWidget { + const TopicListPage({super.key, required this.streamId}); + + final int streamId; + + static AccountRoute buildRoute({ + required BuildContext context, + required int streamId, + }) { + return MaterialAccountWidgetRoute( + context: context, + page: TopicListPage(streamId: streamId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final appBarBackgroundColor = colorSwatchFor( + context, store.subscriptions[streamId]).barBackground; + + return PageRoot(child: Scaffold( + appBar: ZulipAppBar( + backgroundColor: appBarBackgroundColor, + buildTitle: (willCenterTitle) => + _TopicListAppBarTitle(streamId: streamId, willCenterTitle: willCenterTitle), + actions: [ + IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId)))), + ]), + body: _TopicList(streamId: streamId))); + } +} + +// This is adapted from [MessageListAppBarTitle]. +class _TopicListAppBarTitle extends StatelessWidget { + const _TopicListAppBarTitle({ + required this.streamId, + required this.willCenterTitle, + }); + + final int streamId; + final bool willCenterTitle; + + Widget _buildStreamRow(BuildContext context) { + // TODO(#1039) implement a consistent app bar design here + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[streamId]; + final channelIconColor = colorSwatchFor(context, + store.subscriptions[streamId]).iconOnBarBackground; + + // A null [Icon.icon] makes a blank space. + final icon = stream != null ? iconDataForStream(stream) : null; + return Row( + mainAxisSize: MainAxisSize.min, + // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. + // For screenshots of some experiments, see: + // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding(padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Icon(size: 18, icon, color: channelIconColor)), + Flexible(child: Text( + stream?.name ?? zulipLocalizations.unknownChannelName, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600)))), + ]); + } + + @override + Widget build(BuildContext context) { + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; + return SizedBox( + width: double.infinity, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () { + showChannelActionSheet(context, channelId: streamId); + }, + child: Align(alignment: alignment, + child: _buildStreamRow(context)))); + } +} + +class _TopicList extends StatefulWidget { + const _TopicList({required this.streamId}); + + final int streamId; + + @override + State<_TopicList> createState() => _TopicListState(); +} + +class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin { + Unreads? unreadsModel; + // TODO(#1499): store the results on [ChannelStore], and keep them + // up-to-date by handling events + List? lastFetchedTopics; + + @override + void onNewStore() { + unreadsModel?.removeListener(_modelChanged); + final store = PerAccountStoreWidget.of(context); + unreadsModel = store.unreads..addListener(_modelChanged); + _fetchTopics(); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in `unreadsModel`. + }); + } + + void _fetchTopics() async { + // Do nothing when the fetch fails; the topic-list will stay on + // the loading screen, until the user navigates away and back. + // TODO(design) show a nice error message on screen when this fails + final store = PerAccountStoreWidget.of(context); + final result = await getStreamTopics(store.connection, + streamId: widget.streamId, + allowEmptyTopicName: true); + if (!mounted) return; + setState(() { + lastFetchedTopics = result.topics; + }); + } + + @override + Widget build(BuildContext context) { + if (lastFetchedTopics == null) { + return const Center(child: CircularProgressIndicator()); + } + + // TODO(design) handle the rare case when `lastFetchedTopics` is empty + + // This is adapted from parts of the build method on [_InboxPageState]. + final topicItems = <_TopicItemData>[]; + for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) { + final unreadMessageIds = + unreadsModel!.streams[widget.streamId]?[topic] ?? []; + final countInTopic = unreadMessageIds.length; + final hasMention = unreadMessageIds.any((messageId) => + unreadsModel!.mentions.contains(messageId)); + topicItems.add(_TopicItemData( + topic: topic, + unreadCount: countInTopic, + hasMention: hasMention, + // `lastFetchedTopics.maxId` can become outdated when a new message + // arrives or when there are message moves, until we re-fetch. + // TODO(#1499): track changes to this + maxId: maxId, + )); + } + topicItems.sort((a, b) { + final aMaxId = a.maxId; + final bMaxId = b.maxId; + return bMaxId.compareTo(aMaxId); + }); + + return SafeArea( + // Don't pad the bottom here; we want the list content to do that. + bottom: false, + child: ListView.builder( + itemCount: topicItems.length, + itemBuilder: (context, index) => + _TopicItem(streamId: widget.streamId, data: topicItems[index])), + ); + } +} + +class _TopicItemData { + final TopicName topic; + final int unreadCount; + final bool hasMention; + final int maxId; + + const _TopicItemData({ + required this.topic, + required this.unreadCount, + required this.hasMention, + required this.maxId, + }); +} + +// This is adapted from `_TopicItem` in lib/widgets/inbox.dart. +// TODO(#1527) see if we can reuse this in redesign +class _TopicItem extends StatelessWidget { + const _TopicItem({required this.streamId, required this.data}); + + final int streamId; + final _TopicItemData data; + + @override + Widget build(BuildContext context) { + final _TopicItemData( + :topic, :unreadCount, :hasMention, :maxId) = data; + + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic); + final double opacity; + switch (visibilityPolicy) { + case UserTopicVisibilityPolicy.muted: + opacity = 0.5; + case UserTopicVisibilityPolicy.none: + case UserTopicVisibilityPolicy.unmuted: + case UserTopicVisibilityPolicy.followed: + opacity = 1; + case UserTopicVisibilityPolicy.unknown: + assert(false); + opacity = 1; + } + + final visibilityIcon = iconDataForTopicVisibilityPolicy(visibilityPolicy); + + return Material( + color: designVariables.bgMessageRegular, + child: InkWell( + onTap: () { + final narrow = TopicNarrow(streamId, topic); + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, + topic: topic, + someMessageIdInTopic: maxId), + splashFactory: NoSplash.splashFactory, + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8), + child: Row( + spacing: 8, + // In the Figma design, the text and icons on the topic item row + // are aligned to the start on the cross axis + // (i.e., `align-items: flex-start`). The icons are padded down + // 2px relative to the start, to visibly sit on the baseline. + // To account for scaled text, we align everything on the row + // to [CrossAxisAlignment.center] instead ([Row]'s default), + // like we do for the topic items on the inbox page. + // TODO(#1528): align to baseline (and therefore to first line of + // topic name), but with adjustment for icons + // CZO discussion: + // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173252 + children: [ + // A null [Icon.icon] makes a blank space. + _IconMarker(icon: topic.isResolved ? ZulipIcons.check : null), + Expanded(child: Opacity( + opacity: opacity, + child: Text( + style: TextStyle( + fontSize: 17, + height: 20 / 17, + fontStyle: topic.displayName == null ? FontStyle.italic : null, + color: designVariables.textMessage, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), + Opacity(opacity: opacity, child: Row( + spacing: 4, + children: [ + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), + if (unreadCount > 0) _UnreadCountBadge(count: unreadCount), + ])), + ])))); + } +} + +class _IconMarker extends StatelessWidget { + const _IconMarker({required this.icon}); + + final IconData? icon; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final textScaler = MediaQuery.textScalerOf(context); + // Since we align the icons to [CrossAxisAlignment.center], the top padding + // from the Figma design is omitted. + return Icon(icon, + size: textScaler.clamp(maxScaleFactor: 1.5).scale(16), + color: designVariables.textMessage.withFadedAlpha(0.4)); + } +} + +// This is adapted from [UnreadCountBadge]. +// TODO(#1406) see if we can reuse this in redesign +// TODO(#1527) see if we can reuse this in redesign +class _UnreadCountBadge extends StatelessWidget { + const _UnreadCountBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: designVariables.bgCounterUnread, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(count.toString(), + style: TextStyle( + fontSize: 15, + height: 16 / 15, + color: designVariables.labelCounterUnread, + ).merge(weightVariableTextStyle(context, wght: 500))))); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1df9ed3390..b49bf1fd7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: A Zulip client for Android and iOS publish_to: 'none' # Keep the last two numbers equal; see docs/release.md. -version: 0.0.29+29 +version: 0.0.30+30 environment: # We use a recent version of Flutter from its main channel, and diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index b90238ae35..17bd86ee9e 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -37,6 +37,8 @@ extension TopicNameChecks on Subject { } extension StreamConversationChecks on Subject { + Subject get streamId => has((x) => x.streamId, 'streamId'); + Subject get topic => has((x) => x.topic, 'topic'); Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } diff --git a/test/example_data.dart b/test/example_data.dart index d803196269..b87cbb6dc8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -12,6 +12,7 @@ import 'package:zulip/api/route/realm.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/database.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; @@ -625,6 +626,45 @@ GetMessagesResult olderGetMessagesResult({ ); } +int _nextLocalMessageId = 1; + +StreamOutboxMessage streamOutboxMessage({ + int? localMessageId, + int? selfUserId, + int? timestamp, + ZulipStream? stream, + String? topic, + String? content, +}) { + final effectiveStream = stream ?? _stream(streamId: defaultStreamMessageStreamId); + return OutboxMessage.fromConversation( + StreamConversation( + effectiveStream.streamId, TopicName(topic ?? 'topic'), + displayRecipient: null, + ), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: selfUserId ?? selfUser.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as StreamOutboxMessage; +} + +DmOutboxMessage dmOutboxMessage({ + int? localMessageId, + required User from, + required List to, + int? timestamp, + String? content, +}) { + final allRecipientIds = + [from, ...to].map((user) => user.userId).toList()..sort(); + return OutboxMessage.fromConversation( + DmConversation(allRecipientIds: allRecipientIds), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: from.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as DmOutboxMessage; +} + PollWidgetData pollWidgetData({ required String question, required List options, @@ -695,8 +735,8 @@ UserTopicEvent userTopicEvent( ); } -MessageEvent messageEvent(Message message) => - MessageEvent(id: 0, message: message, localMessageId: null); +MessageEvent messageEvent(Message message, {int? localMessageId}) => + MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); DeleteMessageEvent deleteMessageEvent(List messages) { assert(messages.isNotEmpty); diff --git a/test/fake_async_checks.dart b/test/fake_async_checks.dart new file mode 100644 index 0000000000..51c653123a --- /dev/null +++ b/test/fake_async_checks.dart @@ -0,0 +1,6 @@ +import 'package:checks/checks.dart'; +import 'package:fake_async/fake_async.dart'; + +extension FakeTimerChecks on Subject { + Subject get duration => has((t) => t.duration, 'duration'); +} diff --git a/test/model/message_checks.dart b/test/model/message_checks.dart new file mode 100644 index 0000000000..b56cd89a79 --- /dev/null +++ b/test/model/message_checks.dart @@ -0,0 +1,9 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; + +extension OutboxMessageChecks on Subject> { + Subject get localMessageId => has((x) => x.localMessageId, 'localMessageId'); + Subject get state => has((x) => x.state, 'state'); + Subject get hidden => has((x) => x.hidden, 'hidden'); +} diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index ac41d771ec..11f2b3056b 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2153,15 +2153,21 @@ void checkInvariants(MessageListView model) { for (final message in model.messages) { check(model.store.messages)[message.id].isNotNull().identicalTo(message); + } + + final allMessages = >[...model.messages]; + + for (final message in allMessages) { check(model.narrow.containsMessage(message)).isTrue(); - if (message is! StreamMessage) continue; + if (message is! MessageBase) continue; + final conversation = message.conversation; switch (model.narrow) { case CombinedFeedNarrow(): - check(model.store.isTopicVisible(message.streamId, message.topic)) + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) .isTrue(); case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(message.streamId, message.topic)) + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) .isTrue(); case TopicNarrow(): case DmNarrow(): @@ -2204,23 +2210,28 @@ void checkInvariants(MessageListView model) { } int i = 0; - for (int j = 0; j < model.messages.length; j++) { + for (int j = 0; j < allMessages.length; j++) { bool forcedShowSender = false; if (j == 0 - || !haveSameRecipient(model.messages[j-1], model.messages[j])) { + || !haveSameRecipient(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; - } else if (!messagesSameDay(model.messages[j-1], model.messages[j])) { + } else if (!messagesSameDay(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; } - check(model.items[i++]).isA() - ..message.identicalTo(model.messages[j]) - ..content.identicalTo(model.contents[j]) + if (j < model.messages.length) { + check(model.items[i]).isA() + ..message.identicalTo(model.messages[j]) + ..content.identicalTo(model.contents[j]); + } else { + assert(false); + } + check(model.items[i++]).isA() ..showSender.equals( - forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId) + forcedShowSender || allMessages[j].senderId != allMessages[j-1].senderId) ..isLastInBlock.equals( i == model.items.length || switch (model.items[i]) { MessageListMessageItem() diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 1809f0888b..7dff077b1d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,14 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; import 'package:crypto/crypto.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -18,12 +21,17 @@ import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../fake_async_checks.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; +import 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + // These "late" variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -42,10 +50,16 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [store] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + ZulipStream? stream, + int? zulipFeatureLevel, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); - store = eg.store(); + final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + store = eg.store(account: selfAccount, + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); await store.addStream(stream); await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; @@ -54,8 +68,12 @@ void main() { ..addListener(() { notifiedCount++; }); + addTearDown(messageList.dispose); check(messageList).fetched.isFalse(); checkNotNotified(); + + // This cleans up possibly pending timers from [MessageStoreImpl]. + addTearDown(store.dispose); } /// Perform the initial message fetch for [messageList]. @@ -76,6 +94,341 @@ void main() { checkNotified(count: messageList.fetched ? messages.length : 0); } + test('dispose cancels pending timers', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final store = eg.store(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + (store.connection as FakeApiConnection).prepare( + json: SendMessageResult(id: 1).toJson(), + delay: const Duration(seconds: 1)); + unawaited(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')); + check(async.pendingTimers).deepEquals(>[ + (it) => it.isA().duration.equals(kLocalEchoDebounceDuration), + (it) => it.isA().duration.equals(kSendMessageOfferRestoreWaitPeriod), + (it) => it.isA().duration.equals(const Duration(seconds: 1)), + ]); + + store.dispose(); + check(async.pendingTimers).single.duration.equals(const Duration(seconds: 1)); + })); + + group('sendMessage', () { + final stream = eg.stream(); + final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); + late StreamMessage message; + + test('outbox messages get unique localMessageId', () async { + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage(destination: streamDestination, content: 'content'); + } + // [store.outboxMessages] has the same number of keys (localMessageId) + // as the number of sent messages, which are guaranteed to be distinct. + check(store.outboxMessages).keys.length.equals(10); + }); + + Subject checkState() => + check(store.outboxMessages).values.single.state; + + Future prepareOutboxMessage({ + MessageDestination? destination, + int? zulipFeatureLevel, + }) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream, zulipFeatureLevel: zulipFeatureLevel); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: destination ?? streamDestination, content: 'content'); + } + + late Future outboxMessageFailFuture; + Future prepareOutboxMessageToFailAfterDelay(Duration delay) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(httpException: SocketException('failed'), delay: delay); + outboxMessageFailFuture = store.sendMessage( + destination: streamDestination, content: 'content'); + } + + Future receiveMessage([Message? messageReceived]) async { + await store.handleEvent(eg.messageEvent(messageReceived ?? message, + localMessageId: store.outboxMessages.keys.single)); + } + + test('smoke DM: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: DmDestination( + userIds: [eg.selfUser.userId, eg.otherUser.userId])); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); + check(store.outboxMessages).isEmpty(); + })); + + test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: StreamDestination( + stream.streamId, eg.t('foo'))); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); + check(store.outboxMessages).isEmpty(); + })); + + test('hidden -> waiting and never transition to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse( + kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + })); + + test('waiting -> waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + await check(outboxMessageFailFuture).throws(); + })); + + test('waiting -> waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + // Set up a [sendMessage] request that succeeds after enough delay, + // for the outbox message to reach the waitPeriodExpired state. + // TODO extract helper to add prepare an outbox message with a delayed + // successful [sendMessage] request if we have more tests like this + connection.prepare(json: SendMessageResult(id: 1).toJson(), + delay: kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + final future = store.sendMessage( + destination: streamDestination, content: 'content'); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + // Wait till the [sendMessage] request succeeds. + await future; + checkState().equals(OutboxMessageState.waiting); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // returning to the waiting state. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + })); + + group('… -> failed', () { + test('hidden -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + checkState().equals(OutboxMessageState.hidden); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the failed state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.failed); + })); + + test('waiting -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + })); + + test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + })); + }); + + group('… -> (delete)', () { + test('hidden -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('hidden -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay(const Duration(seconds: 1)); + checkState().equals(OutboxMessageState.hidden); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + async.elapse(const Duration(seconds: 1)); + })); + + test('waiting -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('waiting -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + })); + + test('waitPeriodExpired -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + })); + + test('waitPeriodExpired -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the outbox message to be taken (by the user, presumably). + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + })); + + test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('failed -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + })); + }); + + test('when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 370); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('')); + })); + + test('legacy: when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 369); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('(no topic)')); + })); + + test('set timestamp to now when creating outbox messages', () => awaitFakeAsync( + initialTime: eg.timeInPast, + (async) async { + await prepareOutboxMessage(); + check(store.outboxMessages).values.single + .timestamp.equals(eg.utcTimestamp(eg.timeInPast)); + }, + )); + }); + + test('takeOutboxMessage', () async { + final stream = eg.stream(); + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(apiException: eg.apiBadRequest()); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')).throws(); + } + + final localMessageIds = store.outboxMessages.keys.toList(); + store.takeOutboxMessage(localMessageIds.removeAt(5)); + check(store.outboxMessages).keys.deepEquals(localMessageIds); + }); + group('reconcileMessages', () { test('from empty', () async { await prepare(); diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 06c82ed117..c62c56438c 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -7,32 +7,6 @@ import 'package:zulip/model/narrow.dart'; import '../example_data.dart' as eg; import 'narrow_checks.dart'; -/// A [MessageBase] subclass for testing. -// TODO(#1441): switch to outbox-messages instead -sealed class _TestMessage extends MessageBase { - @override - final int? id = null; - - _TestMessage() : super(senderId: eg.selfUser.userId, timestamp: 123456789); -} - -class _TestStreamMessage extends _TestMessage { - @override - final StreamConversation conversation; - - _TestStreamMessage({required ZulipStream stream, required String topic}) - : conversation = StreamConversation( - stream.streamId, TopicName(topic), displayRecipient: null); -} - -class _TestDmMessage extends _TestMessage { - @override - final DmConversation conversation; - - _TestDmMessage({required List allRecipientIds}) - : conversation = DmConversation(allRecipientIds: allRecipientIds); -} - void main() { group('SendableNarrow', () { test('ofMessage: stream message', () { @@ -61,11 +35,11 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -91,13 +65,13 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic2'))).isFalse(); + eg.streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -220,16 +194,19 @@ void main() { }); test('containsMessage with non-Message', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); final narrow = DmNarrow(allRecipientIds: [1, 2], selfUserId: 2); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2]))).isFalse(); + eg.dmOutboxMessage(from: user2, to: []))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2, 3]))).isFalse(); + eg.dmOutboxMessage(from: user2, to: [user3]))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1, 2]))).isTrue(); + eg.dmOutboxMessage(from: user2, to: [user1]))).isTrue(); }); }); @@ -245,9 +222,9 @@ void main() { eg.streamMessage(flags: [MessageFlag.wildcardMentioned]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); @@ -261,9 +238,9 @@ void main() { eg.streamMessage(flags:[MessageFlag.starred]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index eba1505747..0b303b53e2 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -569,7 +569,8 @@ void main() { group('PerAccountStore.sendMessage', () { test('smoke', () async { - final store = eg.store(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + queueId: 'fb67bf8a-c031-47cc-84cf-ed80accacda8')); final connection = store.connection as FakeApiConnection; final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); @@ -585,6 +586,8 @@ void main() { 'topic': 'world', 'content': 'hello', 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), }); }); }); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ccba7e24cc..ffb5d345d8 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1137,7 +1137,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('mismatching account', (tester) async { @@ -1149,7 +1149,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('find account among several', (tester) async { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 6b8011510a..16cc36b096 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -226,6 +226,7 @@ void main() { group('showChannelActionSheet', () { void checkButtons() { check(actionSheetFinder).findsOne(); + checkButton('List of topics'); checkButton('Mark channel as read'); } @@ -244,7 +245,7 @@ void main() { testWidgets('show with no unread messages', (tester) async { await prepare(hasUnreadMessages: false); await showFromSubscriptionList(tester); - check(actionSheetFinder).findsNothing(); + check(findButtonForLabel('Mark channel as read')).findsNothing(); }); testWidgets('show from app bar in channel narrow', (tester) async { @@ -268,6 +269,19 @@ void main() { }); }); + testWidgets('TopicListButton', (tester) async { + await prepare(); + await showFromAppBar(tester, + narrow: ChannelNarrow(someChannel.streamId)); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'some topic foo'), + ]).toJson()); + await tester.tap(findButtonForLabel('List of topics')); + await tester.pumpAndSettle(); + check(find.text('some topic foo')).findsOne(); + }); + group('MarkChannelAsReadButton', () { void checkRequest(int channelId) { check(connection.takeRequests()).single.isA() diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 679f4de190..b4de306aa3 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -15,6 +15,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; @@ -295,6 +296,8 @@ void main() { Future prepareWithContent(WidgetTester tester, String content) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -332,6 +335,8 @@ void main() { Future prepareWithTopic(WidgetTester tester, String topic) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -723,6 +728,8 @@ void main() { }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -829,6 +836,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), @@ -883,6 +892,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); @@ -1401,6 +1412,52 @@ void main() { }); }); + /// Starts an edit interaction from the action sheet's 'Edit message' button. + /// + /// The fetch-raw-content request is prepared with [delay] (default 1s). + Future startEditInteractionFromActionSheet( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + Duration delay = const Duration(seconds: 1), + bool fetchShouldSucceed = true, + }) async { + await tester.longPress(find.byWidgetPredicate((widget) => + widget is MessageWithPossibleSender && widget.item.message.id == messageId)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + final findEditButton = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.ensureVisible(findEditButton); + if (fetchShouldSucceed) { + connection.prepare(delay: delay, + json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); + } else { + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + } + await tester.tap(findEditButton); + await tester.pump(); + await tester.pump(); + connection.takeRequests(); + } + + Future expectAndHandleDiscardConfirmation( + WidgetTester tester, { + required String expectedMessage, + required bool shouldContinue, + }) async { + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: expectedMessage, + expectedActionButtonText: 'Discard'); + if (shouldContinue) { + await tester.tap(find.byWidget(actionButton)); + } else { + await tester.tap(find.byWidget(cancelButton)); + } + } + group('edit message', () { final channel = eg.stream(); final topic = 'topic'; @@ -1419,6 +1476,8 @@ void main() { int msgIdInNarrow(Narrow narrow) => msgInNarrow(narrow).id; Future prepareEditMessage(WidgetTester tester, {required Narrow narrow}) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -1451,36 +1510,6 @@ void main() { check(connection.lastRequest).equals(lastRequest); } - /// Starts an interaction from the action sheet's 'Edit message' button. - /// - /// The fetch-raw-content request is prepared with [delay] (default 1s). - Future startInteractionFromActionSheet( - WidgetTester tester, { - required int messageId, - String originalRawContent = 'foo', - Duration delay = const Duration(seconds: 1), - bool fetchShouldSucceed = true, - }) async { - await tester.longPress(find.byWidgetPredicate((widget) => - widget is MessageWithPossibleSender && widget.item.message.id == messageId)); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - final findEditButton = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); - await tester.ensureVisible(findEditButton); - if (fetchShouldSucceed) { - connection.prepare(delay: delay, - json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); - } else { - connection.prepare(apiException: eg.apiBadRequest(), delay: delay); - } - await tester.tap(findEditButton); - await tester.pump(); - await tester.pump(); - connection.takeRequests(); - } - /// Starts an interaction by tapping a failed edit in the message list. Future startInteractionFromRestoreFailedEdit( WidgetTester tester, { @@ -1488,7 +1517,7 @@ void main() { String originalRawContent = 'foo', String newContent = 'bar', }) async { - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: originalRawContent); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, newContent); @@ -1544,7 +1573,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); @@ -1595,19 +1624,12 @@ void main() { testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); - Future expectAndHandleDiscardConfirmation( - WidgetTester tester, { + Future expectAndHandleDiscardForEditConfirmation(WidgetTester tester, { required bool shouldContinue, - }) async { - final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, - expectedTitle: 'Discard the message you’re writing?', + }) { + return expectAndHandleDiscardConfirmation(tester, expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', - expectedActionButtonText: 'Discard'); - if (shouldContinue) { - await tester.tap(find.byWidget(actionButton)); - } else { - await tester.tap(find.byWidget(cancelButton)); - } + shouldContinue: shouldContinue); } // Test the "Discard…?" confirmation dialog when you tap "Edit message" in @@ -1624,8 +1646,8 @@ void main() { await enterContent(tester, 'composing new message'); // Expect confirmation dialog; tap Cancel - await startInteractionFromActionSheet(tester, messageId: messageId); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await startEditInteractionFromActionSheet(tester, messageId: messageId); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); check(connection.takeRequests()).isEmpty(); // fetch-raw-content request wasn't actually sent; // take back its prepared response @@ -1638,9 +1660,9 @@ void main() { checkContentInputValue(tester, 'composing new message…'); // Try again, but this time tap Discard and expect to enter an edit session - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request @@ -1672,7 +1694,7 @@ void main() { final messageId = msgIdInNarrow(narrow); await prepareEditMessage(tester, narrow: narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, 'bar'); @@ -1689,7 +1711,7 @@ void main() { // Expect confirmation dialog; tap Cancel await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); checkNotInEditingMode(tester, narrow: narrow, expectedContentText: 'composing new message'); @@ -1699,7 +1721,7 @@ void main() { // Try again, but this time tap Discard and expect to enter edit session await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); checkContentInputValue(tester, 'bar'); await enterContent(tester, 'baz'); @@ -1729,7 +1751,7 @@ void main() { checkNotInEditingMode(tester, narrow: narrow); final messageId = msgIdInNarrow(narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo', fetchShouldSucceed: false); @@ -1777,7 +1799,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, delay: Duration(seconds: 5)); await checkAwaitingRawMessageContent(tester); await tester.pump(duringFetchRawContentRequest! @@ -1796,7 +1818,7 @@ void main() { // We've canceled the previous edit session, so we should be able to // do a new edit-message session… - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index df05b4f0cc..81cc384ef8 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -11,6 +11,7 @@ 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/api/model/narrow.dart'; +import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; @@ -18,6 +19,7 @@ import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; @@ -27,6 +29,8 @@ import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/theme.dart'; +import 'package:zulip/widgets/topic_list.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -208,6 +212,36 @@ void main() { channel.name, eg.defaultRealmEmptyTopicDisplayName); }); + void testChannelIconInChannelRow(IconData expectedIcon, { + required bool isWebPublic, + required bool inviteOnly, + }) { + final description = 'channel icon in channel row; ' + 'web-public: $isWebPublic, invite-only: $inviteOnly'; + testWidgets(description, (tester) async { + final color = 0xff95a5fd; + + final channel = eg.stream(isWebPublic: isWebPublic, inviteOnly: inviteOnly); + final subscription = eg.subscription(channel, color: color); + + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + subscriptions: [subscription], + messages: [eg.streamMessage(stream: channel)]); + + final iconElement = tester.element(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.byIcon(expectedIcon))); + + check(Theme.brightnessOf(iconElement)).equals(Brightness.light); + check(iconElement.widget as Icon).color.equals(Color(0xff5972fc)); + }); + } + testChannelIconInChannelRow(ZulipIcons.globe, isWebPublic: true, inviteOnly: false); + testChannelIconInChannelRow(ZulipIcons.lock, isWebPublic: false, inviteOnly: true); + testChannelIconInChannelRow(ZulipIcons.hash_sign, isWebPublic: false, inviteOnly: false); + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -228,6 +262,25 @@ void main() { .equals(ChannelNarrow(channel.streamId)); }); + testWidgets('has topic-list action for topic narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: eg.topicNarrow(channel.streamId, 'topic foo'), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + testWidgets('show topic visibility policy for topic narrows', (tester) async { final channel = eg.stream(); const topic = 'topic'; @@ -243,6 +296,25 @@ void main() { of: find.byType(MessageListAppBarTitle), matching: find.byIcon(ZulipIcons.mute))).findsOne(); }); + + testWidgets('has topic-list action for channel narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); }); group('presents message content appropriately', () { @@ -282,17 +354,17 @@ void main() { return widget.color; } - check(backgroundColor()).isSameColorAs(MessageListTheme.light.bgMessageRegular); + check(backgroundColor()).isSameColorAs(DesignVariables.light.bgMessageRegular); tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; await tester.pump(); await tester.pump(kThemeAnimationDuration * 0.4); - final expectedLerped = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4); + final expectedLerped = DesignVariables.light.lerp(DesignVariables.dark, 0.4); check(backgroundColor()).isSameColorAs(expectedLerped.bgMessageRegular); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor()).isSameColorAs(MessageListTheme.dark.bgMessageRegular); + check(backgroundColor()).isSameColorAs(DesignVariables.dark.bgMessageRegular); }); group('fetch initial batch of messages', () { @@ -943,7 +1015,8 @@ void main() { connection.prepare(json: SendMessageResult(id: 1).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); - await tester.pump(); + await tester.pump(Duration.zero); + final localMessageId = store.outboxMessages.keys.single; check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -952,8 +1025,12 @@ void main() { 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', - 'read_by_sender': 'true'}); - await tester.pumpAndSettle(); + 'read_by_sender': 'true', + 'queue_id': store.queueId, + 'local_id': localMessageId.toString()}); + // Remove the outbox message and its timers created when sending message. + await store.handleEvent( + eg.messageEvent(message, localMessageId: localMessageId)); }); testWidgets('Move to narrow with existing messages', (tester) async { @@ -1366,6 +1443,30 @@ void main() { }); group('MessageWithPossibleSender', () { + testWidgets('known user', (tester) async { + final user = eg.user(fullName: 'Old Name'); + await setupMessageListPage(tester, + messages: [eg.streamMessage(sender: user)], + users: [user]); + + check(find.widgetWithText(MessageWithPossibleSender, 'Old Name')).findsOne(); + + // If the user's name changes, the sender row should update. + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + await tester.pump(); + check(find.widgetWithText(MessageWithPossibleSender, 'New Name')).findsOne(); + }); + + testWidgets('unknown user', (tester) async { + final user = eg.user(fullName: 'Some User'); + await setupMessageListPage(tester, messages: [eg.streamMessage(sender: user)]); + check(store.getUser(user.userId)).isNull(); + + // The sender row should fall back to the name in the message. + check(find.widgetWithText(MessageWithPossibleSender, 'Some User')).findsOne(); + }); + testWidgets('Updates avatar on RealmUserUpdateEvent', (tester) async { addTearDown(testBinding.reset); diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart new file mode 100644 index 0000000000..cf76ff3917 --- /dev/null +++ b/test/widgets/topic_list_test.dart @@ -0,0 +1,330 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + + Future prepare(WidgetTester tester, { + ZulipStream? channel, + List? topics, + List userTopics = const [], + List? messages, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addUser(eg.selfUser); + channel ??= eg.stream(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + for (final userTopic in userTopics) { + await store.addUserTopic( + channel, userTopic.topicName.apiName, userTopic.visibilityPolicy); + } + topics ??= [eg.getStreamTopicsEntry()]; + messages ??= [eg.streamMessage(stream: channel, topic: topics.first.name.apiName)]; + await store.addMessages(messages); + + connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/${channel.streamId}/topics') + ..url.queryParameters.deepEquals({'allow_empty_topic_name': 'true'}); + } + + group('app bar', () { + testWidgets('unknown channel name', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.widgetWithText(ZulipAppBar, '(unknown channel)')).findsOne(); + }); + + testWidgets('navigate to channel feed', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.message_feed)); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.descendant( + of: find.byType(MessageListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + + testWidgets('show channel action sheet', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel, + messages: [eg.streamMessage(stream: channel)]); + + await tester.longPress(find.text('channel foo')); + await tester.pump(Duration(milliseconds: 100)); // bottom-sheet animation + check(find.text('Mark channel as read')).findsOne(); + }); + }); + + testWidgets('show loading indicator', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson(), + delay: Duration(seconds: 1), + ); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + + await tester.pump(Duration(seconds: 1)); + check(find.byType(CircularProgressIndicator)).findsNothing(); + }); + + testWidgets('fetch again when navigating away and back', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + + // Start from a message list page in a channel narrow. + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: ChannelNarrow(channel.streamId)))); + await tester.pump(); + + // Tap "TOPICS" button navigating to the topic-list page… + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic A')]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsOne(); + + // … go back to the message list page… + await tester.pageBack(); + await tester.pump(); + + // … then back to the topic-list page, expecting to fetch again. + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsNothing(); + check(find.text('topic B')).findsOne(); + }); + + Finder topicItemFinder = find.descendant( + of: find.byType(ListView), + matching: find.byType(Material)); + + Finder findInTopicItemAt(int index, Finder finder) => find.descendant( + of: topicItemFinder.at(index), + matching: finder); + + testWidgets('show topic action sheet', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic foo')]); + await tester.longPress(topicItemFinder); + await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation + + connection.prepare(json: {}); + await tester.tap(find.text('Mute topic')); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': channel.streamId.toString(), + 'topic': 'topic foo', + 'visibility_policy': UserTopicVisibilityPolicy.muted.apiValue.toString(), + }); + }); + + testWidgets('sort topics by maxId', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: 'A', maxId: 3), + eg.getStreamTopicsEntry(name: 'B', maxId: 2), + eg.getStreamTopicsEntry(name: 'C', maxId: 4), + ]); + + check(findInTopicItemAt(0, find.text('C'))).findsOne(); + check(findInTopicItemAt(1, find.text('A'))).findsOne(); + check(findInTopicItemAt(2, find.text('B'))).findsOne(); + }); + + testWidgets('resolved and unresolved topics', (tester) async { + final resolvedTopic = TopicName('resolved').resolve(); + final unresolvedTopic = TopicName('unresolved'); + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: resolvedTopic.apiName), + eg.getStreamTopicsEntry(maxId: 1, name: unresolvedTopic.apiName), + ]); + + assert(resolvedTopic.displayName == '✔ resolved', resolvedTopic.displayName); + check(findInTopicItemAt(0, find.text('✔ resolved'))).findsNothing(); + + check(findInTopicItemAt(0, find.text('resolved'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('unresolved'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon)).hitTestable()) + .findsNothing(); + }); + + testWidgets('handle empty topics', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: ''), + ]); + check(findInTopicItemAt(0, + find.text(eg.defaultRealmEmptyTopicDisplayName))).findsOne(); + }); + + group('unreads', () { + testWidgets('muted and non-muted topics', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'muted'), + eg.getStreamTopicsEntry(maxId: 1, name: 'non-muted'), + ], + userTopics: [ + eg.userTopicItem(channel, 'muted', UserTopicVisibilityPolicy.muted), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + ]); + + check(findInTopicItemAt(0, find.text('1'))).findsOne(); + check(findInTopicItemAt(0, find.text('muted'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.mute).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('2'))).findsOne(); + check(findInTopicItemAt(1, find.text('non-muted'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon).hitTestable())) + .findsNothing(); + }); + + testWidgets('with and without unread mentions', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'not mentioned'), + eg.getStreamTopicsEntry(maxId: 1, name: 'mentioned'), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned', + flags: [MessageFlag.mentioned, MessageFlag.read]), + eg.streamMessage(stream: channel, topic: 'mentioned', + flags: [MessageFlag.mentioned]), + ]); + + check(findInTopicItemAt(0, find.text('2'))).findsOne(); + check(findInTopicItemAt(0, find.text('not mentioned'))).findsOne(); + check(findInTopicItemAt(0, find.byType(Icons))).findsNothing(); + + check(findInTopicItemAt(1, find.text('1'))).findsOne(); + check(findInTopicItemAt(1, find.text('mentioned'))).findsOne(); + check(findInTopicItemAt(1, find.byIcon(ZulipIcons.at_sign))).findsOne(); + }); + }); + + group('topic visibility', () { + testWidgets('default', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')]); + + check(find.descendant(of: topicItemFinder, + matching: find.byType(Icons))).findsNothing(); + }); + + testWidgets('muted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.muted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.mute))).findsOne(); + }); + + testWidgets('unmuted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.unmuted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.unmute))).findsOne(); + }); + + testWidgets('followed', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.followed), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.follow))).findsOne(); + }); + }); +}