diff --git a/README.md b/README.md index ee6477dd67..1bc12b65b6 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Zulip Flutter (beta) +# Zulip Flutter -A Zulip client for Android and iOS, using Flutter. +The official Zulip app for Android and iOS, built with Flutter. -This app is currently [in beta][beta]. -When it's ready, it [will become the new][] official mobile Zulip client. -To see what work is planned before that launch, -see the [milestones][] and the [project board][]. +This app [was launched][] as the main Zulip mobile app +in June 2025. +It replaced the [previous Zulip mobile app][] built with React Native. -[beta]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1708728 -[will become the new]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1582367 -[milestones]: https://github.com/zulip/zulip-flutter/milestones?direction=asc&sort=title -[project board]: https://github.com/orgs/zulip/projects/5/views/4 +[was launched]: https://blog.zulip.com/flutter-mobile-app-launch +[previous Zulip mobile app]: https://github.com/zulip/zulip-mobile#readme -## Using Zulip +## Get the app -To use Zulip on iOS or Android, install the [official mobile Zulip client][]. - -You can also [try out this beta app][beta]. - -[official mobile Zulip client]: https://github.com/zulip/zulip-mobile#readme +Release versions of the app are available here: +* [Zulip for iOS](https://apps.apple.com/app/zulip/id1203036395) + on Apple's App Store +* [Zulip for Android](https://play.google.com/store/apps/details?id=com.zulipmobile) + on the Google Play Store + * Or if you don't use Google Play, you can + [download an APK](https://github.com/zulip/zulip-flutter/releases/latest) + from the official build we post on GitHub. ## Contributing @@ -27,8 +27,8 @@ You can also [try out this beta app][beta]. Contributions to this app are welcome. If you're looking to participate in Google Summer of Code with Zulip, -this is one of the projects we intend to accept [GSoC 2025 applications][gsoc] -for. +this was among the projects we accepted [GSoC applications][gsoc] for +in 2024 and 2025. [gsoc]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app @@ -289,7 +289,7 @@ good time to [report them as issues][dart-test-tracker]. #### Server compatibility -We support Zulip Server 4.0 and later. +We support Zulip Server 7.0 and later. For API features added in newer versions, use `TODO(server-N)` comments (like those you see in the existing code.) diff --git a/android/app/build.gradle b/android/app/build.gradle index 640d7a1fdb..c56eeb88a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,7 +38,7 @@ android { } defaultConfig { - applicationId "com.zulip.flutter" + applicationId "com.zulipmobile" minSdkVersion 28 targetSdkVersion flutter.targetSdkVersion // These are synced to local.properties from pubspec.yaml by the flutter tool. @@ -78,6 +78,7 @@ android { checkAllWarnings = true warningsAsErrors = true baseline = file("lint-baseline.xml") + disable += ['AndroidGradlePluginVersion'] } } diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml index 006b010637..be85415f4e 100644 --- a/android/app/lint-baseline.xml +++ b/android/app/lint-baseline.xml @@ -1,11 +1,12 @@ - + + + file="$GRADLE_USER_HOME/caches/modules-2/files-2.1/org.apache.tika/tika-core/3.2.0/9232bb3c71f231e8228f570071c0e1ea29d40115/tika-core-3.2.0.jar"/> diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a0f602e899..fa2c342af5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ { return listOf(result) @@ -128,7 +128,7 @@ data class NotificationChannel ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -171,7 +171,7 @@ data class AndroidIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -215,7 +215,7 @@ data class PendingIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -249,7 +249,7 @@ data class InboxStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -299,7 +299,7 @@ data class Person ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -339,7 +339,7 @@ data class MessagingStyleMessage ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -382,7 +382,7 @@ data class MessagingStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -419,7 +419,7 @@ data class Notification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -459,7 +459,7 @@ data class StatusBarNotification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -509,11 +509,11 @@ data class StoredNotificationSound ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } -private open class NotificationsPigeonCodec : StandardMessageCodec() { +private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { @@ -721,7 +721,7 @@ interface AndroidNotificationHostApi { companion object { /** The codec used by AndroidNotificationHostApi. */ val codec: MessageCodec by lazy { - NotificationsPigeonCodec() + AndroidNotificationsPigeonCodec() } /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads @@ -737,7 +737,7 @@ interface AndroidNotificationHostApi { api.createNotificationChannel(channelArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -752,7 +752,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getNotificationChannels()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -770,7 +770,7 @@ interface AndroidNotificationHostApi { api.deleteNotificationChannel(channelIdArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -785,7 +785,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.listStoredSoundsInNotificationsDirectory()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -803,7 +803,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -835,7 +835,7 @@ interface AndroidNotificationHostApi { api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -852,7 +852,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -869,7 +869,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotifications(desiredExtrasArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -888,7 +888,7 @@ interface AndroidNotificationHostApi { api.cancel(tagArg, idArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp index d9cb74391c..29ac2c64df 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index d02707d8d7..491a79190f 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp index 68435f6ce8..509773e658 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp index 19a645180f..a1460987f2 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp index e83a533ff5..baa177b2e0 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index d71f15fe5a..b484b79c87 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp index 061cc27b1b..a5d6517a86 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index 7a4c431361..25f3ead329 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp index 62f6bf3911..f6e4c671db 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index 98157df216..cbd20f3a9d 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/gradle.properties b/android/gradle.properties index e9d14e8cb1..6e0f68603b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -6,7 +6,7 @@ android.enableJetifier=true # Defining them here makes them available both in # settings.gradle and in the build.gradle files. -agpVersion=8.9.1 +agpVersion=8.10.1 # Generally update this to the version found in recent releases # of Android Studio, as listed in this table: diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 479cd23287..877fe51457 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -6,7 +6,7 @@ # the wrapper is the one from the new Gradle too.) distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/assets/app-icons/zulip-beta-combined.svg b/assets/app-icons/zulip-beta-combined.svg deleted file mode 100644 index ea6d487bf6..0000000000 --- a/assets/app-icons/zulip-beta-combined.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/assets/app-icons/zulip-white-z-beta-on-transparent.svg b/assets/app-icons/zulip-white-z-beta-on-transparent.svg deleted file mode 100644 index ed8a592ef1..0000000000 --- a/assets/app-icons/zulip-white-z-beta-on-transparent.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 84e19a9cfa..85f393019a 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/check_circle_checked.svg b/assets/icons/check_circle_checked.svg new file mode 100644 index 0000000000..df4b5694a0 --- /dev/null +++ b/assets/icons/check_circle_checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_circle_unchecked.svg b/assets/icons/check_circle_unchecked.svg new file mode 100644 index 0000000000..f60d58ca9f --- /dev/null +++ b/assets/icons/check_circle_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000000..c5cc095bbe --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000..cc2c3587d7 --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/person.svg b/assets/icons/person.svg new file mode 100644 index 0000000000..6a35686e46 --- /dev/null +++ b/assets/icons/person.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000..a5b1b7e078 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/remove.svg b/assets/icons/remove.svg new file mode 100644 index 0000000000..dcb1763c46 --- /dev/null +++ b/assets/icons/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000000..171e4109ec --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + 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/icons/user.svg b/assets/icons/two_person.svg similarity index 100% rename from assets/icons/user.svg rename to assets/icons/two_person.svg diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb new file mode 100644 index 0000000000..aa89465183 --- /dev/null +++ b/assets/l10n/app_de.arb @@ -0,0 +1,1196 @@ +{ + "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." + }, + "aboutPageOpenSourceLicenses": "Open-Source-Lizenzen", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "newDmSheetComposeButtonLabel": "Verfassen", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Neue DN", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Neue DN", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "unknownChannelName": "(unbekannter Kanal)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxTopicHintText": "Thema", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxEnterTopicOrSkipHintText": "Gib ein Thema ein (leer lassen für “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "contentValidationErrorTooLong": "Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "contentValidationErrorEmpty": "Du hast nichts zum Senden!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "errorDialogLearnMore": "Mehr erfahren", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "snackBarDetails": "Details", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "loginMethodDivider": "ODER", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "topicValidationErrorTooLong": "Länge des Themas sollte 60 Zeichen nicht überschreiten.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "spoilerDefaultHeaderText": "Spoiler", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAllAsReadLabel": "Alle Nachrichten als gelesen markieren", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "userRoleOwner": "Besitzer", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "Administrator", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "inboxEmptyPlaceholder": "Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten um den kombinierten Feed oder die Kanalliste anzusehen.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "recentDmConversationsSectionHeader": "Direktnachrichten", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "recentDmConversationsEmptyPlaceholder": "Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "starredMessagesPageTitle": "Markierte Nachrichten", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsPageTitle": "Kanäle", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelsEmptyPlaceholder": "Du hast noch keine Kanäle abonniert.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "onePersonTyping": "{typist} tippt…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "errorReactionAddingFailedTitle": "Hinzufügen der Reaktion fehlgeschlagen", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "wildcardMentionTopicDescription": "Thema benachrichtigen", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "messageIsEditedLabel": "BEARBEITET", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingTitle": "THEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "errorNotificationOpenAccountNotFound": "Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "initialAnchorSettingTitle": "Nachrichten-Feed öffnen bei", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Erste ungelesene Nachricht", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "revealButtonLabel": "Nachricht für stummgeschalteten Absender anzeigen", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "actionSheetOptionListOfTopics": "Themenliste", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionUnresolveTopic": "Als ungelöst markieren", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Thema konnte nicht als gelöst markiert werden", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "actionSheetOptionCopyMessageText": "Nachrichtentext kopieren", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "Link zur Nachricht kopieren", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Markierung aufheben", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "errorCouldNotFetchMessageSource": "Konnte Nachrichtenquelle nicht abrufen.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorLoginFailedTitle": "Anmeldung fehlgeschlagen", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorCouldNotOpenLink": "Link konnte nicht geöffnet werden: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorMuteTopicFailed": "Konnte Thema nicht stummschalten", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorCouldNotEditMessageTitle": "Konnte Nachricht nicht bearbeiten", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerLabelEditMessage": "Nachricht bearbeiten", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Abbrechen", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "preparingEditMessageContentInput": "Bereite vor…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "discardDraftConfirmationDialogConfirmButton": "Verwerfen", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "messageListGroupYouAndOthers": "Du und {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "unknownUserName": "(Nutzer:in unbekannt)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dialogCancel": "Abbrechen", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "errorMalformedResponseWithCause": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "userRoleModerator": "Moderator", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleGuest": "Gast", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleMember": "Mitglied", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleUnknown": "Unbekannt", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "unpinnedSubscriptionsLabel": "Nicht angeheftet", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "wildcardMentionChannelDescription": "Kanal benachrichtigen", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionStreamDescription": "Stream benachrichtigen", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "experimentalFeatureSettingsWarning": "Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "savingMessageEditLabel": "SPEICHERE BEARBEITUNG…", + "@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": "BEARBEITUNG NICHT GESPEICHERT", + "@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": "Die Nachricht, die du schreibst, verwerfen?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "dialogContinue": "Fortsetzen", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "loginServerUrlLabel": "Deine Zulip Server URL", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginErrorMissingEmail": "Bitte gib deine E-Mail ein.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginErrorMissingPassword": "Bitte gib dein Passwort ein.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "actionSheetOptionQuoteMessage": "Nachricht zitieren", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingAlways": "Immer", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionStarMessage": "Nachricht markieren", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "errorAccountLoggedInTitle": "Account bereits angemeldet", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "actionSheetOptionEditMessage": "Nachricht bearbeiten", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "composeBoxGenericContentHint": "Eine Nachricht eingeben", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "actionSheetOptionMarkAsUnread": "Ab hier als ungelesen markieren", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "errorUnresolveTopicFailedTitle": "Thema konnte nicht als ungelöst markiert werden", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "logOutConfirmationDialogMessage": "Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "actionSheetOptionMarkTopicAsRead": "Thema als gelesen markieren", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorHandlingEventDetails": "Fehler beim Verarbeiten eines Zulip-Ereignisses von {serverUrl}; Wird wiederholt.\n\nFehler: {error}\n\nEreignis: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "markReadOnScrollSettingConversationsDescription": "Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markAsReadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als gelesen markiert.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "contentValidationErrorUploadInProgress": "Bitte warte bis das Hochladen abgeschlossen ist.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "composeBoxBannerButtonSave": "Speichern", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "loginEmailLabel": "E-Mail-Adresse", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "dialogClose": "Schließen", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "loginHidePassword": "Passwort verstecken", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "markAsUnreadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als ungelesen markiert.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "topicsButtonLabel": "THEMEN", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "markReadOnScrollSettingTitle": "Nachrichten beim Scrollen als gelesen markieren", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "errorMarkAsReadFailedTitle": "Als gelesen markieren fehlgeschlagen", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "pinnedSubscriptionsLabel": "Angeheftet", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "initialAnchorSettingDescription": "Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingNever": "Nie", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "initialAnchorSettingNewestAlways": "Neueste Nachricht", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Nur in Unterhaltungsansichten", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorAccountLoggedIn": "Der Account {email} auf {server} ist bereits in deiner Account-Liste.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorCopyingFailed": "Kopieren fehlgeschlagen", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "actionSheetOptionHideMutedMessage": "Stummgeschaltete Nachricht wieder ausblenden", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorMessageNotSent": "Nachricht nicht versendet", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorMessageEditNotSaved": "Nachricht nicht gespeichert", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "editAlreadyInProgressTitle": "Kann Nachricht nicht bearbeiten", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "discardDraftForEditConfirmationDialogMessage": "Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetNoUsersFound": "Keine Nutzer:innen gefunden", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "newDmSheetSearchHintEmpty": "Füge ein oder mehrere Nutzer:innen hinzu", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Füge weitere Nutzer:in hinzu…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "lightboxVideoCurrentPosition": "Aktuelle Position", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxCopyLinkTooltip": "Link kopieren", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "serverUrlValidationErrorInvalidUrl": "Bitte gib eine gültige URL ein.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorRequestFailed": "Netzwerkanfrage fehlgeschlagen: HTTP Status {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "errorVideoPlayerFailed": "Video konnte nicht wiedergegeben werden.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorEmpty": "Bitte gib eine URL ein.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "messageNotSentLabel": "NACHRICHT NICHT GESENDET", + "@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.)" + }, + "mutedUser": "Stummgeschaltete:r Nutzer:in", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "aboutPageTapToView": "Antippen zum Ansehen", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "tryAnotherAccountMessage": "Dein Account bei {url} benötigt einige Zeit zum Laden.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "tryAnotherAccountButton": "Anderen Account ausprobieren", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Abmelden", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Abmelden?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Abmelden", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Account hinzufügen", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "Direktnachricht senden", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "permissionsNeededTitle": "Berechtigungen erforderlich", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "errorCouldNotShowUserProfile": "Nutzerprofil kann nicht angezeigt werden.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededOpenSettings": "Einstellungen öffnen", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "permissionsDeniedCameraAccess": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionUnfollowTopic": "Thema entfolgen", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "permissionsDeniedReadExternalStorage": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "actionSheetOptionMarkChannelAsRead": "Kanal als gelesen markieren", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionMuteTopic": "Thema stummschalten", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Thema lautschalten", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionFollowTopic": "Thema folgen", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "Als gelöst markieren", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionShare": "Teilen", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Etwas ist schiefgelaufen", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "Ein unerwarteter Fehler ist aufgetreten.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorFilesTooLarge": "{num, plural, =1{Datei ist} other{{num} Dateien sind}} größer als das Serverlimit von {maxFileUploadSizeMib} MiB und {num, plural, =1{wird} other{{num} werden}} nicht hochgeladen:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "errorFilesTooLargeTitle": "{num, plural, =1{Datei} other{Dateien}} zu groß", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorLoginInvalidInputTitle": "Ungültige Eingabe", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginCouldNotConnect": "Verbindung zu Server fehlgeschlagen:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "Konnte nicht verbinden", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Diese Nachricht scheint nicht zu existieren.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Zitat fehlgeschlagen", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorServerMessage": "Der Server sagte:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerDetails": "Fehler beim Verbinden mit Zulip auf {serverUrl}. Wird wiederholt:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "Fehler beim Verbinden mit Zulip. Wiederhole…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorHandlingEventTitle": "Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorCouldNotOpenLinkTitle": "Link kann nicht geöffnet werden", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorUnfollowTopicFailed": "Konnte Thema nicht entfolgen", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorFollowTopicFailed": "Konnte Thema nicht folgen", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorStarMessageFailedTitle": "Konnte Nachricht nicht markieren", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnmuteTopicFailed": "Konnte Thema nicht lautschalten", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorSharingFailed": "Teilen fehlgeschlagen", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorUnstarMessageFailedTitle": "Konnte Markierung nicht von der Nachricht entfernen", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "successLinkCopied": "Link kopiert", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageTextCopied": "Nachrichtentext kopiert", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "successMessageLinkCopied": "Nachrichtenlink kopiert", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerDeactivatedDmLabel": "Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "errorBannerCannotPostInChannelLabel": "Du hast keine Berechtigung in diesen Kanal zu schreiben.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxAttachFilesTooltip": "Dateien anhängen", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "Bilder oder Videos anhängen", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "Ein Foto aufnehmen", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxSelfDmContentHint": "Schreibe etwas", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxDmContentHint": "Nachricht an @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "composeBoxGroupDmContentHint": "Nachricht an Gruppe", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "Nachricht an {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "composeBoxSendTooltip": "Senden", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "composeBoxUploadingFilename": "Lade {filename} hoch…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxLoadingMessage": "(lade Nachricht {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "dmsWithOthersPageTitle": "DNs mit {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "messageListGroupYouWithYourself": "Nachrichten mit dir selbst", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "contentValidationErrorQuoteAndReplyInProgress": "Bitte warte bis das Zitat abgeschlossen ist.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorDialogContinue": "OK", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "Fehler", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "loginFormSubmitLabel": "Anmelden", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "lightboxVideoDuration": "Videolänge", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginPageTitle": "Anmelden", + "@loginPageTitle": { + "description": "Title for login page." + }, + "signInWithFoo": "Anmelden mit {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginAddAnAccountPageTitle": "Account hinzufügen", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "loginPasswordLabel": "Passwort", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginUsernameLabel": "Benutzername", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginErrorMissingUsername": "Bitte gib deinen Benutzernamen ein.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "errorServerVersionUnsupportedMessage": "{url} nutzt Zulip Server {zulipVersion}, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "topicValidationErrorMandatoryButEmpty": "Themen sind in dieser Organisation erforderlich.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorMalformedResponse": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorInvalidApiKeyMessage": "Dein Account bei {url} konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "Der Server hat eine ungültige Antwort gesendet.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorNetworkRequestFailed": "Netzwerkanfrage fehlgeschlagen", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "serverUrlValidationErrorNoUseEmail": "Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorUnsupportedScheme": "Die Server-URL muss mit http:// oder https:// beginnen.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAsReadInProgress": "Nachrichten werden als gelesen markiert…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "today": "Heute", + "@today": { + "description": "Term to use to reference the current day." + }, + "markAsUnreadInProgress": "Nachrichten werden als ungelesen markiert…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Als ungelesen markieren fehlgeschlagen", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "yesterday": "Gestern", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "inboxPageTitle": "Eingang", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "recentDmConversationsPageTitle": "Direktnachrichten", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "combinedFeedPageTitle": "Kombinierter Feed", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "mentionsPageTitle": "Erwähnungen", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "mainMenuMyProfile": "Mein Profil", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "channelFeedButtonTooltip": "Kanal-Feed", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "notifGroupDmConversationLabel": "{senderFullName} an dich und {numOthers, plural, =1{1 weitere:n} other{{numOthers} weitere}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "notifSelfUser": "Du", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "Du", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "twoPeopleTyping": "{typist} und {otherTypist} tippen…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "Mehrere Leute tippen…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "wildcardMentionAll": "alle", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionEveryone": "jeder", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "Kanal", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionStream": "Stream", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "Thema", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionAllDmDescription": "Empfänger benachrichtigen", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "pollVoterNames": "{voterNames}", + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "type": "String", + "example": "Alice, Bob, Chad" + } + } + }, + "messageIsMovedLabel": "VERSCHOBEN", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "Dunkel", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "Hell", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingSystem": "System", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "openLinksWithInAppBrowser": "Links mit In-App-Browser öffnen", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetOptionsMissing": "Diese Umfrage hat noch keine Optionen.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "pollWidgetQuestionMissing": "Keine Frage.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "experimentalFeatureSettingsPageTitle": "Experimentelle Funktionen", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "errorNotificationOpenTitle": "Fehler beim Öffnen der Benachrichtigung", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionRemovingFailedTitle": "Entfernen der Reaktion fehlgeschlagen", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "mehr", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "scrollToBottomTooltip": "Nach unten Scrollen", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "type": "String", + "example": "foo.txt" + }, + "size": { + "type": "String", + "example": "20.2" + } + } + }, + "errorFailedToUploadFileTitle": "Fehler beim Upload der Datei: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "dmsWithYourselfPageTitle": "DNs mit dir selbst", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "noEarlierMessages": "Keine früheren Nachrichten", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "emojiPickerSearchEmoji": "Emoji suchen", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "upgradeWelcomeDialogTitle": "Willkommen bei der neuen Zulip-App!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Sieh dir den Ankündigungs-Blogpost an!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Los gehts", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + } +} diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..b4ad14149a 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -15,6 +15,22 @@ "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, + "upgradeWelcomeDialogTitle": "Welcome to the new Zulip app!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "You’ll find a familiar experience in a faster, sleeker package.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Check out the announcement blog post!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Let's go", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, "chooseAccountPageTitle": "Choose account", "@chooseAccountPageTitle": { "description": "Title for the page to choose between Zulip accounts." @@ -84,6 +100,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,13 +148,17 @@ "@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." }, - "actionSheetOptionQuoteAndReply": "Quote and reply", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." + "actionSheetOptionQuoteMessage": "Quote message", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." }, "actionSheetOptionStarMessage": "Star message", "@actionSheetOptionStarMessage": { @@ -144,6 +168,10 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "actionSheetOptionEditMessage": "Edit message", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, "actionSheetOptionMarkTopicAsRead": "Mark topic as read", "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." @@ -168,7 +196,7 @@ "server": {"type": "String", "example": "https://example.com"} } }, - "errorCouldNotFetchMessageSource": "Could not fetch message source", + "errorCouldNotFetchMessageSource": "Could not fetch message source.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -219,6 +247,10 @@ "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." }, + "errorMessageEditNotSaved": "Message not saved", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, "errorLoginCouldNotConnect": "Failed to connect to server:\n{url}", "@errorLoginCouldNotConnect": { "description": "Error message when the app could not connect to the server.", @@ -309,6 +341,10 @@ "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, + "errorCouldNotEditMessageTitle": "Could not edit message", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -329,6 +365,50 @@ "@errorBannerCannotPostInChannelLabel": { "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, + "composeBoxBannerLabelEditMessage": "Edit message", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Cancel", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Save", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Cannot edit message", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "An edit is already in progress. Please wait for it to complete.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SAVING EDIT…", + "@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": "EDIT NOT SAVED", + "@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": "Discard the message you’re writing?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title 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." + }, + "discardDraftForOutboxConfirmationDialogMessage": "When you restore an unsent message, the content that was previously in the compose box is discarded.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Discard", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." @@ -345,6 +425,30 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, + "newDmSheetComposeButtonLabel": "Compose", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "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.", @@ -367,6 +471,10 @@ "destination": {"type": "String", "example": "#channel name > topic name"} } }, + "preparingEditMessageContentInput": "Preparing…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, "composeBoxSendTooltip": "Send", "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." @@ -379,6 +487,13 @@ "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, + "composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": {"type": "String", "example": "general chat"} + } + }, "composeBoxUploadingFilename": "Uploading {filename}…", "@composeBoxUploadingFilename": { "description": "Placeholder in compose box showing the specified file is currently uploading.", @@ -415,6 +530,14 @@ "others": {"type": "String", "example": "Alice, Bob"} } }, + "emptyMessageList": "There are no messages here.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "No search results.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, "messageListGroupYouWithYourself": "Messages with yourself", "@messageListGroupYouWithYourself": { "description": "Message list recipient header for a DM group that only includes yourself." @@ -554,7 +677,7 @@ "url": {"type": "String", "example": "http://chat.example.com/"} } }, - "errorInvalidResponse": "The server sent an invalid response", + "errorInvalidResponse": "The server sent an invalid response.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -584,7 +707,7 @@ "httpStatus": {"type": "int", "example": "500"} } }, - "errorVideoPlayerFailed": "Unable to play the video", + "errorVideoPlayerFailed": "Unable to play the video.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -674,10 +797,26 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "searchMessagesPageTitle": "Search", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Search", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Clear", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, "inboxPageTitle": "Inbox", "@inboxPageTitle": { "description": "Title for the page with unreads." }, + "inboxEmptyPlaceholder": "There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." @@ -686,6 +825,10 @@ "@recentDmConversationsSectionHeader": { "description": "Heading for direct messages section on the 'Inbox' message view." }, + "recentDmConversationsEmptyPlaceholder": "You have no direct messages yet! Why not start the conversation?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, "combinedFeedPageTitle": "Combined feed", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." @@ -702,10 +845,18 @@ "@channelsPageTitle": { "description": "Title for the page with a list of subscribed channels." }, + "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, "mainMenuMyProfile": "My profile", "@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" @@ -726,10 +877,6 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "subscriptionListNoChannels": "No channels found", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "notifSelfUser": "You", "@notifSelfUser": { "description": "Display name for the user themself, to show after replying in an Android notification" @@ -801,6 +948,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.", @@ -836,6 +987,50 @@ "@pollWidgetOptionsMissing": { "description": "Text to display for a poll when it has no options" }, + "initialAnchorSettingTitle": "Open message feeds at", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "You can choose whether message feeds open at your first unread message or at the newest messages.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "First unread message", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "First unread message in conversation views, newest message elsewhere", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Newest message", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingTitle": "Mark messages as read on scroll", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "When scrolling through messages, should they automatically be marked as read?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Always", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Never", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Only in conversation views", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Messages will be automatically marked as read only when viewing a single topic or direct message conversation.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, "experimentalFeatureSettingsPageTitle": "Experimental features", "@experimentalFeatureSettingsPageTitle": { "description": "Title of settings page for experimental, in-development features" @@ -848,9 +1043,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": { @@ -872,6 +1067,14 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, + "revealButtonLabel": "Reveal message", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Muted user", + "@mutedUser": { + "description": "Text to display in place of a muted user's name." + }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." diff --git a/assets/l10n/app_en_GB.arb b/assets/l10n/app_en_GB.arb new file mode 100644 index 0000000000..3e9859203f --- /dev/null +++ b/assets/l10n/app_en_GB.arb @@ -0,0 +1,6 @@ +{ + "topicValidationErrorMandatoryButEmpty": "Topics are required in this organisation.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + } +} diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb new file mode 100644 index 0000000000..6771c4368d --- /dev/null +++ b/assets/l10n/app_it.arb @@ -0,0 +1,1196 @@ +{ + "aboutPageTapToView": "Tap per visualizzare", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "settingsPageTitle": "Impostazioni", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "Cambia account", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountButton": "Prova un altro account", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Esci", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Disconnettersi?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogMessage": "Per utilizzare questo account in futuro, bisognerà reinserire l'URL della propria organizzazione e le informazioni del proprio account.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Esci", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Aggiungi un account", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "errorCouldNotShowUserProfile": "Impossibile mostrare il profilo utente.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededTitle": "Permessi necessari", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "Apri le impostazioni", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "Segna il canale come letto", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "Elenco degli argomenti", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionUnfollowTopic": "Non seguire più l'argomento", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "aboutPageTitle": "Su Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "Versione app", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "Licenze open-source", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "Scegli account", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "actionSheetOptionFollowTopic": "Segui argomento", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "permissionsDeniedReadExternalStorage": "Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "tryAnotherAccountMessage": "Il caricamento dell'account su {url} sta richiedendo un po' di tempo.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "actionSheetOptionMuteTopic": "Silenzia argomento", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Riattiva argomento", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "profileButtonSendDirectMessage": "Invia un messaggio diretto", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "permissionsDeniedCameraAccess": "Per caricare un'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionResolveTopic": "Segna come risolto", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come risolto", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come irrisolto", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageLink": "Copia il collegamento al messaggio", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "Segna come non letto da qui", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionHideMutedMessage": "Nascondi nuovamente il messaggio disattivato", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionEditMessage": "Modifica messaggio", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorAccountLoggedInTitle": "Account già registrato", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorLoginInvalidInputTitle": "Ingresso non valido", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginFailedTitle": "Accesso non riuscito", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageEditNotSaved": "Messaggio non salvato", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorCouldNotConnectTitle": "Impossibile connettersi", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Quel messaggio sembra non esistere.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Citazione non riuscita", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorConnectingToServerShort": "Errore di connessione a Zulip. Nuovo tentativo…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorHandlingEventTitle": "Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorFailedToUploadFileTitle": "Impossibile caricare il file: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "errorCouldNotFetchMessageSource": "Impossibile recuperare l'origine del messaggio.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorMessageNotSent": "Messaggio non inviato", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "actionSheetOptionShare": "Condividi", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Messaggio normale", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "errorLoginCouldNotConnect": "Impossibile connettersi al server:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorWebAuthOperationalError": "Si è verificato un errore imprevisto.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedIn": "L'account {email} su {server} è già presente nell'elenco account.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorServerMessage": "Il server ha detto:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorCopyingFailed": "Copia non riuscita", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "actionSheetOptionUnresolveTopic": "Segna come irrisolto", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "actionSheetOptionCopyMessageText": "Copia il testo del messaggio", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionStarMessage": "Messaggio speciale", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "Segna l'argomento come letto", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Qualcosa è andato storto", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "type": "String", + "example": "foo.txt" + }, + "size": { + "type": "String", + "example": "20.2" + } + } + }, + "errorConnectingToServerDetails": "Errore durante la connessione a Zulip su {serverUrl}. Verrà effettuato un nuovo tentativo:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorCouldNotOpenLinkTitle": "Impossibile aprire il collegamento", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorMuteTopicFailed": "Impossibile silenziare l'argomento", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorFollowTopicFailed": "Impossibile seguire l'argomento", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorUnfollowTopicFailed": "Impossibile smettere di seguire l'argomento", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "Condivisione fallita", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "Impossibile contrassegnare il messaggio come speciale", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnmuteTopicFailed": "Impossibile de-silenziare l'argomento", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "actionSheetOptionQuoteMessage": "Cita messaggio", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "errorCouldNotEditMessageTitle": "Impossibile modificare il messaggio", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "errorUnstarMessageFailedTitle": "Impossibile contrassegnare il messaggio come normale", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotOpenLink": "Impossibile aprire il collegamento: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "successLinkCopied": "Collegamento copiato", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "errorHandlingEventDetails": "Errore nella gestione di un evento Zulip da {serverUrl}; verrà effettuato un nuovo tentativo.\n\nErrore: {error}\n\nEvento: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "successMessageLinkCopied": "Collegamento messaggio copiato", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "serverUrlValidationErrorUnsupportedScheme": "L'URL del server deve iniziare con http:// o https://.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "recentDmConversationsEmptyPlaceholder": "Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "errorBannerDeactivatedDmLabel": "Non è possibile inviare messaggi agli utenti disattivati.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "starredMessagesPageTitle": "Messaggi speciali", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "successMessageTextCopied": "Testo messaggio copiato", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "composeBoxBannerButtonSave": "Salva", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Impossibile modificare il messaggio", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Una modifica è già in corso. Attendere il completamento.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SALVATAGGIO MODIFICA…", + "@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": "MODIFICA NON SALVATA", + "@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": "Scartare il messaggio che si sta scrivendo?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFromCameraTooltip": "Fai una foto", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "Batti un messaggio", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetComposeButtonLabel": "Componi", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Nuovo MD", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmSheetSearchHintEmpty": "Aggiungi uno o più utenti", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetNoUsersFound": "Nessun utente trovato", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "Messaggia @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "newDmFabButtonLabel": "Nuovo MD", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "composeBoxSelfDmContentHint": "Annota qualcosa", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxLoadingMessage": "(caricamento messaggio {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "messageListGroupYouAndOthers": "Tu e {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithYourselfPageTitle": "MD con te stesso", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "dmsWithOthersPageTitle": "MD con {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "contentValidationErrorQuoteAndReplyInProgress": "Attendere il completamento del commento.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorDialogLearnMore": "Scopri di più", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "lightboxCopyLinkTooltip": "Copia collegamento", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "loginFormSubmitLabel": "Accesso", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "loginServerUrlLabel": "URL del server Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "Nascondi password", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "errorMalformedResponse": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorRequestFailed": "Richiesta di rete non riuscita: stato HTTP {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "Inserire un URL valido.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "markAsReadInProgress": "Contrassegno dei messaggi come letti…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "Contrassegno come letto non riuscito", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadInProgress": "Contrassegno dei messaggi come non letti…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Contrassegno come non letti non riuscito", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "userRoleOwner": "Proprietario", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleModerator": "Moderatore", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleMember": "Membro", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleGuest": "Ospite", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleUnknown": "Sconosciuto", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "recentDmConversationsPageTitle": "Messaggi diretti", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "recentDmConversationsSectionHeader": "Messaggi diretti", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "channelsPageTitle": "Canali", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelFeedButtonTooltip": "Feed del canale", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "twoPeopleTyping": "{typist} e {otherTypist} stanno scrivendo…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "Molte persone stanno scrivendo…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "wildcardMentionEveryone": "ognuno", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "flusso", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "messageIsEditedLabel": "MODIFICATO", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "Scuro", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "Chiaro", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "openLinksWithInAppBrowser": "Apri i collegamenti con il browser in-app", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetOptionsMissing": "Questo sondaggio non ha ancora opzioni.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "errorNotificationOpenAccountNotFound": "Impossibile trovare l'account associato a questa notifica.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "initialAnchorSettingTitle": "Apri i feed dei messaggi su", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Primo messaggio non letto", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "markReadOnScrollSettingAlways": "Sempre", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorNotificationOpenTitle": "Impossibile aprire la notifica", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionAddingFailedTitle": "Aggiunta della reazione non riuscita", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "Rimozione della reazione non riuscita", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "altro", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "experimentalFeatureSettingsWarning": "Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "signInWithFoo": "Accedi con {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "discardDraftForEditConfirmationDialogMessage": "Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "lightboxVideoCurrentPosition": "Posizione corrente", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "loginAddAnAccountPageTitle": "Aggiungi account", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "errorInvalidResponse": "Il server ha inviato una risposta non valida.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "serverUrlValidationErrorEmpty": "Inserire un URL.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "snackBarDetails": "Dettagli", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "composeBoxTopicHintText": "Argomento", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Abbandona", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFilesTooltip": "Allega file", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "errorDialogTitle": "Errore", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "composeBoxAttachMediaTooltip": "Allega immagini o video", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "unknownUserName": "(utente sconosciuto)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "newDmSheetSearchHintSomeSelected": "Aggiungi un altro utente…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "composeBoxGroupDmContentHint": "Gruppo di messaggi", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "Messaggia {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "Preparazione…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "Invia", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(canale sconosciuto)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxUploadingFilename": "Caricamento {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "messageListGroupYouWithYourself": "Messaggi con te stesso", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "composeBoxEnterTopicOrSkipHintText": "Inserisci un argomento (salta per \"{defaultTopicName}\")", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "loginErrorMissingEmail": "Inserire l'email.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "dialogContinue": "Continua", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "contentValidationErrorTooLong": "La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "loginErrorMissingPassword": "Inserire la propria password.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "loginEmailLabel": "Indirizzo email", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "pollWidgetQuestionMissing": "Nessuna domanda.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "contentValidationErrorEmpty": "Non devi inviare nulla!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "loginPageTitle": "Accesso", + "@loginPageTitle": { + "description": "Title for login page." + }, + "contentValidationErrorUploadInProgress": "Attendere il completamento del caricamento.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "dialogCancel": "Annulla", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "errorDialogContinue": "Ok", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "dialogClose": "Chiudi", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "combinedFeedPageTitle": "Feed combinato", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "lightboxVideoDuration": "Durata video", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginMethodDivider": "O", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "loginUsernameLabel": "Nomeutente", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginPasswordLabel": "Password", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingUsername": "Inserire il proprio nomeutente.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "notifSelfUser": "Tu", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "topicValidationErrorTooLong": "La lunghezza dell'argomento non deve superare i 60 caratteri.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "today": "Oggi", + "@today": { + "description": "Term to use to reference the current day." + }, + "topicValidationErrorMandatoryButEmpty": "In questa organizzazione sono richiesti degli argomenti.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "markAllAsReadLabel": "Segna tutti i messaggi come letti", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "errorInvalidApiKeyMessage": "L'account su {url} non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorNetworkRequestFailed": "Richiesta di rete non riuscita", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "errorMalformedResponseWithCause": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "wildcardMentionAll": "tutti", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "channelsEmptyPlaceholder": "Non sei ancora iscritto ad alcun canale.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "inboxPageTitle": "Inbox", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "errorVideoPlayerFailed": "Impossibile riprodurre il video.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorNoUseEmail": "Inserire l'URL del server, non il proprio indirizzo email.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "userRoleAdministrator": "Amministratore", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "yesterday": "Ieri", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "themeSettingSystem": "Sistema", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "inboxEmptyPlaceholder": "Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l'elenco dei canali.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "mentionsPageTitle": "Menzioni", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "mainMenuMyProfile": "Il mio profilo", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "ARGOMENTI", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "reactedEmojiSelfUser": "Tu", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "onePersonTyping": "{typist} sta scrivendo…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "wildcardMentionChannel": "canale", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopic": "argomento", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "messageIsMovedLabel": "SPOSTATO", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageNotSentLabel": "MESSAGGIO NON INVIATO", + "@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.", + "placeholders": { + "voterNames": { + "type": "String", + "example": "Alice, Bob, Chad" + } + } + }, + "themeSettingTitle": "TEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "composeBoxBannerLabelEditMessage": "Modifica messaggio", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "markReadOnScrollSettingTitle": "Segna i messaggi come letti durante lo scorrimento", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "composeBoxBannerButtonCancel": "Annulla", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "initialAnchorSettingFirstUnreadConversations": "Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingConversations": "Solo nelle visualizzazioni delle conversazioni", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "experimentalFeatureSettingsPageTitle": "Caratteristiche sperimentali", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "errorBannerCannotPostInChannelLabel": "Non hai l'autorizzazione per postare su questo canale.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "initialAnchorSettingNewestAlways": "Messaggio più recente", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingNever": "Mai", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorFilesTooLargeTitle": "{num, plural, =1{File} other{File}} troppo grande/i", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "spoilerDefaultHeaderText": "Spoiler", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsUnreadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagi}} come non letto/i.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "pinnedSubscriptionsLabel": "Bloccato", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "unpinnedSubscriptionsLabel": "Non bloccato", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "wildcardMentionStreamDescription": "Notifica flusso", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "Notifica destinatari", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionTopicDescription": "Notifica argomento", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "errorFilesTooLarge": "{num, plural, =1{file è} other{{num} file sono}} più grande/i del limite del server di {maxFileUploadSizeMib} MiB e non verrà/anno caricato/i:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "noEarlierMessages": "Nessun messaggio precedente", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "revealButtonLabel": "Mostra messaggio per mittente silenziato", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Utente silenziato", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "scrollToBottomTooltip": "Scorri fino in fondo", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "markAsReadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagei}} come letto/i.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorServerVersionUnsupportedMessage": "{url} sta usando Zulip Server {zulipVersion}, che non è supportato. La versione minima supportata è Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "wildcardMentionChannelDescription": "Notifica canale", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "notifGroupDmConversationLabel": "{senderFullName} a te e {numOthers, plural, =1{1 altro} other{{numOthers} altri}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "emojiPickerSearchEmoji": "Cerca emoji", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "upgradeWelcomeDialogLinkText": "Date un'occhiata al post dell'annuncio sul blog!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Andiamo", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Troverai un'esperienza familiare in un pacchetto più veloce ed elegante.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogTitle": "Benvenuti alla nuova app Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index da8d2c8664..364cc0c921 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -53,10 +53,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Odpowiedz cytując", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Oznacz gwiazdką", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." @@ -73,7 +69,7 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, - "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.", + "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.", "@logOutConfirmationDialogMessage": { "description": "Message for a confirmation dialog for logging out." }, @@ -107,7 +103,7 @@ } } }, - "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości", + "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -377,11 +373,11 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, - "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera", + "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, - "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo", + "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -557,10 +553,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" @@ -703,7 +695,7 @@ "example": "http://chat.example.com/" } }, - "tryAnotherAccountButton": "Sprawdź inne konto", + "tryAnotherAccountButton": "Użyj innego konta", "@tryAnotherAccountButton": { "description": "Label for loading screen button prompting user to try another account." }, @@ -871,10 +863,6 @@ "@pinnedSubscriptionsLabel": { "description": "Label for the list of pinned subscribed channels." }, - "subscriptionListNoChannels": "Nie odnaleziono kanałów", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "unknownChannelName": "(nieznany kanał)", "@unknownChannelName": { "description": "Replacement name for channel when it cannot be found in the store." @@ -1006,5 +994,223 @@ "example": "4.0" } } + }, + "composeBoxEnterTopicOrSkipHintText": "Wpisz tytuł wątku (pomiń aby uzyskać “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "actionSheetOptionEditMessage": "Zmień wiadomość", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorMessageEditNotSaved": "Nie zapisano wiadomości", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorCouldNotEditMessageTitle": "Nie można zmienić wiadomości", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerLabelEditMessage": "Zmień wiadomość", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "Operacja zmiany w toku. Zaczekaj na jej zakończenie.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "ZAPIS ZMIANY…", + "@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": "NIE ZAPISANO ZMIANY", + "@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": "Czy chcesz przerwać szykowanie wpisu?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title 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": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxBannerButtonCancel": "Anuluj", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "preparingEditMessageContentInput": "Przygotowywanie…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "editAlreadyInProgressTitle": "Nie udało się zapisać zmiany", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "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." + }, + "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" + }, + "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.)" + }, + "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "newDmSheetComposeButtonLabel": "Utwórz", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "inboxEmptyPlaceholder": "Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "channelsEmptyPlaceholder": "Nie śledzisz żadnego z kanałów.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "initialAnchorSettingDescription": "Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Pierwsza nieprzeczytana wiadomość", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "Pokaż wiadomości w porządku", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "initialAnchorSettingFirstUnreadConversations": "Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Najnowsza wiadomość", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionQuoteMessage": "Cytuj wiadomość", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingTitle": "Oznacz wiadomości jako przeczytane przy przwijaniu", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Zawsze", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Tylko w widoku dyskusji", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Nigdy", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogTitle": "Witaj w nowej apce Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Sprawdź blog pod kątem obwieszczenia!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Zaczynajmy", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "emptyMessageList": "Póki co brak wiadomości.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "revealButtonLabel": "Odsłoń wiadomość", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "emptyMessageListSearch": "Brak wyników wyszukiwania.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "searchMessagesPageTitle": "Szukaj", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Szukaj", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Wyczyść", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 57cf48e3e0..d0b4488cda 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -75,10 +75,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Ответить с цитированием", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Отметить сообщение", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." @@ -113,7 +109,7 @@ } } }, - "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения", + "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -287,10 +283,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 +385,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 +517,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." }, @@ -803,10 +795,6 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "subscriptionListNoChannels": "Каналы не найдены", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "wildcardMentionAll": "все", "@wildcardMentionAll": { "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." @@ -1006,5 +994,223 @@ "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" + }, + "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." + }, + "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" + }, + "errorNotificationOpenAccountNotFound": "Учетная запись, связанная с этим уведомлением, не найдена.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "channelsEmptyPlaceholder": "Вы еще не подписаны ни на один канал.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "У вас пока нет личных сообщений! Почему бы не начать беседу?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "newDmSheetComposeButtonLabel": "Написать", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "inboxEmptyPlaceholder": "Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "initialAnchorSettingNewestAlways": "Самое новое сообщение", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "Где открывать ленту сообщений", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "При восстановлении неотправленного сообщения содержимое поля редактирования очищается.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "initialAnchorSettingFirstUnreadAlways": "Первое непрочитанное сообщение", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionQuoteMessage": "Цитировать сообщение", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingTitle": "Отмечать сообщения как прочитанные при прокрутке", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "При прокрутке сообщений автоматически отмечать их как прочитанные?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Только при просмотре бесед", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Никогда", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Всегда", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogDismiss": "Приступим!", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Вы найдете привычные возможности в более быстром и легком приложении.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogTitle": "Добро пожаловать в новое приложение Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Ознакомьтесь с анонсом в блоге!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "emptyMessageList": "Здесь нет сообщений.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "Ничего не найдено.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "searchMessagesPageTitle": "Поиск", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Поиск", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Очистить", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "revealButtonLabel": "Показать сообщение", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." } } diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index ba700eb33c..4d6279d7b1 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -119,10 +119,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Citovať a odpovedať", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Ohviezdičkovať správu", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb new file mode 100644 index 0000000000..6131411e54 --- /dev/null +++ b/assets/l10n/app_sl.arb @@ -0,0 +1,1196 @@ +{ + "aboutPageTitle": "O Zulipu", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "permissionsDeniedCameraAccess": "Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionFollowTopic": "Sledi temi", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "errorFailedToUploadFileTitle": "Nalaganje datoteke ni uspelo: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxBannerButtonCancel": "Prekliči", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Shrani", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "composeBoxEnterTopicOrSkipHintText": "Vnesite temo (ali pustite prazno za »{defaultTopicName}«)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "loginFormSubmitLabel": "Prijava", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "userRoleModerator": "Moderator", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "recentDmConversationsSectionHeader": "Neposredna sporočila", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "wildcardMentionEveryone": "vsi", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "kanal", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "themeSettingDark": "Temna", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "errorCouldNotFetchMessageSource": "Ni bilo mogoče pridobiti vira sporočila.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "markAsReadComplete": "Označeno je {num, plural, one{{num} sporočilo} two{{num} sporočili} few{{num} sporočila} other{{num} sporočil}} kot prebrano.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "successLinkCopied": "Povezava je bila kopirana", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "permissionsDeniedReadExternalStorage": "Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "actionSheetOptionUnfollowTopic": "Prenehaj slediti temi", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "Označi kot razrešeno", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Označi kot nerazrešeno", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Neuspela označitev teme kot razrešene", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Neuspela označitev teme kot nerazrešene", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageText": "Kopiraj besedilo sporočila", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "Kopiraj povezavo do sporočila", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "Od tu naprej označi kot neprebrano", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionShare": "Deli", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionStarMessage": "Označi sporočilo z zvezdico", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Odstrani zvezdico s sporočila", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "Uredi sporočilo", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "Označi temo kot prebrano", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Nekaj je šlo narobe", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "Prišlo je do nepričakovane napake.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "Račun je že prijavljen", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorAccountLoggedIn": "Račun {email} na {server} je že na vašem seznamu računov.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorCopyingFailed": "Kopiranje ni uspelo", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "type": "String", + "example": "foo.txt" + }, + "size": { + "type": "String", + "example": "20.2" + } + } + }, + "errorLoginInvalidInputTitle": "Neveljaven vnos", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginFailedTitle": "Prijava ni uspela", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageNotSent": "Pošiljanje sporočila ni uspelo", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorMessageEditNotSaved": "Sporočilo ni bilo shranjeno", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorLoginCouldNotConnect": "Ni se mogoče povezati s strežnikom:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "Povezave ni bilo mogoče vzpostaviti", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Zdi se, da to sporočilo ne obstaja.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Citiranje ni uspelo", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorServerMessage": "Strežnik je sporočil:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerDetails": "Napaka pri povezovanju z Zulipom na {serverUrl}. Poskusili bomo znova:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "Napaka pri povezovanju z Zulipom. Poskušamo znova…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorCouldNotOpenLinkTitle": "Povezave ni mogoče odpreti", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorMuteTopicFailed": "Utišanje teme ni uspelo", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorCouldNotOpenLink": "Povezave ni bilo mogoče odpreti: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorUnmuteTopicFailed": "Preklic utišanja teme ni uspel", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorFollowTopicFailed": "Sledenje temi ni uspelo", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorUnfollowTopicFailed": "Prenehanje sledenja temi ni uspelo", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "Deljenje ni uspelo", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "Sporočila ni bilo mogoče označiti z zvezdico", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnstarMessageFailedTitle": "Sporočilu ni bilo mogoče odstraniti zvezdice", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotEditMessageTitle": "Sporočila ni mogoče urediti", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "errorBannerDeactivatedDmLabel": "Deaktiviranim uporabnikom ne morete pošiljati sporočil.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "successMessageLinkCopied": "Povezava do sporočila je bila kopirana", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerCannotPostInChannelLabel": "Nimate dovoljenja za objavljanje v tem kanalu.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxBannerLabelEditMessage": "Uredi sporočilo", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Urejanje sporočila ni mogoče", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Urejanje je že v teku. Počakajte, da se konča.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SHRANJEVANJE SPREMEMB…", + "@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": "UREJANJE NI SHRANJENO", + "@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.)" + }, + "discardDraftConfirmationDialogConfirmButton": "Zavrzi", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFilesTooltip": "Pripni datoteke", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "Pripni fotografije ali videoposnetke", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "Fotografiraj", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "Vnesite sporočilo", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetComposeButtonLabel": "Napiši", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Novo neposredno sporočilo", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Novo neposredno sporočilo", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintEmpty": "Dodajte enega ali več uporabnikov", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetNoUsersFound": "Ni zadetkov med uporabniki", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "Sporočilo @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "composeBoxGroupDmContentHint": "Skupinsko sporočilo", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxSelfDmContentHint": "Zapišite opombo zase", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxChannelContentHint": "Sporočilo {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "Pripravljanje…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "Pošlji", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(neznan kanal)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxTopicHintText": "Tema", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxUploadingFilename": "Nalaganje {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxLoadingMessage": "(nalaganje sporočila {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "unknownUserName": "(neznan uporabnik)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dmsWithYourselfPageTitle": "Neposredna sporočila s samim seboj", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "messageListGroupYouAndOthers": "Vi in {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithOthersPageTitle": "Neposredna sporočila z {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "contentValidationErrorTooLong": "Dolžina sporočila ne sme presegati 10000 znakov.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "contentValidationErrorEmpty": "Ni vsebine za pošiljanje!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "contentValidationErrorUploadInProgress": "Počakajte, da se nalaganje konča.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "dialogCancel": "Prekliči", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "dialogContinue": "Nadaljuj", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "dialogClose": "Zapri", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "errorDialogLearnMore": "Več o tem", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "errorDialogContinue": "V redu", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "Napaka", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "snackBarDetails": "Podrobnosti", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "lightboxCopyLinkTooltip": "Kopiraj povezavo", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "lightboxVideoCurrentPosition": "Trenutni položaj", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "Trajanje videa", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginPageTitle": "Prijava", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginMethodDivider": "ALI", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "loginAddAnAccountPageTitle": "Dodaj račun", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "signInWithFoo": "Prijava z {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginServerUrlLabel": "URL strežnika Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "Skrij geslo", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "loginEmailLabel": "E-poštni naslov", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "loginErrorMissingEmail": "Vnesite svoj e-poštni naslov.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "Geslo", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingPassword": "Vnesite svoje geslo.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "loginUsernameLabel": "Uporabniško ime", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginErrorMissingUsername": "Vnesite svoje uporabniško ime.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "topicValidationErrorTooLong": "Dolžina teme ne sme presegati 60 znakov.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "topicValidationErrorMandatoryButEmpty": "Teme so v tej organizaciji obvezne.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorServerVersionUnsupportedMessage": "{url} uporablja strežnik Zulip {zulipVersion}, ki ni podprt. Najnižja podprta različica je strežnik Zulip {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "errorInvalidApiKeyMessage": "Vašega računa na {url} ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "Strežnik je poslal neveljaven odgovor.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorMalformedResponse": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorMalformedResponseWithCause": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "errorVideoPlayerFailed": "Videa ni mogoče predvajati.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorEmpty": "Vnesite URL.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "serverUrlValidationErrorNoUseEmail": "Vnesite URL strežnika, ne vašega e-poštnega naslova.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorUnsupportedScheme": "URL strežnika se mora začeti s http:// ali https://.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAllAsReadLabel": "Označi vsa sporočila kot prebrana", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "spoilerDefaultHeaderText": "Skrito", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsReadInProgress": "Označevanje sporočil kot prebranih…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "Označevanje kot prebrano ni uspelo", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadInProgress": "Označevanje sporočil kot neprebranih…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Označevanje kot neprebrano ni uspelo", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "today": "Danes", + "@today": { + "description": "Term to use to reference the current day." + }, + "yesterday": "Včeraj", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "Lastnik", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "Skrbnik", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleMember": "Član", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleGuest": "Gost", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleUnknown": "Neznano", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "inboxPageTitle": "Nabiralnik", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "recentDmConversationsPageTitle": "Neposredna sporočila", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "mentionsPageTitle": "Omembe", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "combinedFeedPageTitle": "Združen prikaz", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "starredMessagesPageTitle": "Sporočila z zvezdico", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsPageTitle": "Kanali", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelsEmptyPlaceholder": "Niste še naročeni na noben kanal.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "mainMenuMyProfile": "Moj profil", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "TEME", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "channelFeedButtonTooltip": "Sporočila kanala", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "notifGroupDmConversationLabel": "{senderFullName} vam in {numOthers, plural, =1{1 drugi osebi} other{{numOthers} drugim osebam}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "notifSelfUser": "Vi", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "Vi", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "onePersonTyping": "{typist} tipka…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "manyPeopleTyping": "Več oseb tipka…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "twoPeopleTyping": "{typist} in {otherTypist} tipkata…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "wildcardMentionAll": "vsi", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "tok", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "tema", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionChannelDescription": "Obvesti kanal", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionStreamDescription": "Obvesti tok", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "Obvesti prejemnike", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionTopicDescription": "Obvesti udeležence teme", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "messageIsEditedLabel": "UREJENO", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsMovedLabel": "PREMAKNJENO", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageNotSentLabel": "SPOROČILO NI POSLANO", + "@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.", + "placeholders": { + "voterNames": { + "type": "String", + "example": "Alice, Bob, Chad" + } + } + }, + "themeSettingTitle": "TEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingLight": "Svetla", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingSystem": "Sistemska", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "openLinksWithInAppBrowser": "Odpri povezave v brskalniku znotraj aplikacije", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetQuestionMissing": "Brez vprašanja.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "experimentalFeatureSettingsPageTitle": "Eksperimentalne funkcije", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "pollWidgetOptionsMissing": "Ta anketa še nima odgovorov.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "experimentalFeatureSettingsWarning": "Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "errorNotificationOpenAccountNotFound": "Računa, povezanega s tem obvestilom, ni bilo mogoče najti.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "errorReactionAddingFailedTitle": "Reakcije ni bilo mogoče dodati", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "Reakcije ni bilo mogoče odstraniti", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "več", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "emojiPickerSearchEmoji": "Iskanje emojijev", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "noEarlierMessages": "Ni starejših sporočil", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "revealButtonLabel": "Prikaži sporočilo utišanega pošiljatelja", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Uporabnik je utišan", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "appVersionUnknownPlaceholder": "(...)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "scrollToBottomTooltip": "Premakni se na konec", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "recentDmConversationsEmptyPlaceholder": "Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "errorFilesTooLarge": "{num, plural, one{{num} datoteka presega} two{{num} datoteki presegata} few{{num} datoteke presegajo} other{{num} datotek presega}} omejitev velikosti strežnika ({maxFileUploadSizeMib} MiB) in {num, plural, one{ne bo naložena} two{ne bosta naloženi} few{ne bodo naložene} other{ne bodo naložene}}:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "inboxEmptyPlaceholder": "V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "successMessageTextCopied": "Besedilo sporočila je bilo kopirano", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "contentValidationErrorQuoteAndReplyInProgress": "Počakajte, da se citat zaključi.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorNetworkRequestFailed": "Omrežna zahteva je spodletela", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "aboutPageAppVersion": "Različica aplikacije", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "Odprtokodne licence", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "aboutPageTapToView": "Dotaknite se za ogled", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "Izberite račun", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "settingsPageTitle": "Nastavitve", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "Preklopi račun", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountMessage": "Nalaganje vašega računa na {url} traja dlje kot običajno.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "tryAnotherAccountButton": "Poskusite z drugim računom", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Odjava", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Se želite odjaviti?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogMessage": "Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Odjavi se", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Dodaj račun", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "Pošlji neposredno sporočilo", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "errorCouldNotShowUserProfile": "Uporabniškega profila ni mogoče prikazati.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededTitle": "Potrebna so dovoljenja", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "Odpri nastavitve", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "Označi kanal kot prebran", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "Seznam tem", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMuteTopic": "Utišaj temo", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Prekliči utišanje teme", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "errorFilesTooLargeTitle": "\"{num, plural, one{{num} datoteka je prevelika} two{{num} datoteki sta preveliki} few{{num} datoteke so prevelike} other{{num} datotek je prevelikih}}\"", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsUnreadComplete": "{num, plural, one{Označeno je {num} sporočilo kot neprebrano} two{Označeni sta {num} sporočili kot neprebrani} few{Označena so {num} sporočila kot neprebrana} other{Označeno je {num} sporočil kot neprebranih}}.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorHandlingEventTitle": "Napaka pri obravnavi posodobitve. Povezujemo se znova…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "actionSheetOptionHideMutedMessage": "Znova skrij utišano sporočilo", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorHandlingEventDetails": "Napaka pri obravnavi posodobitve iz strežnika {serverUrl}; poskusili bomo znova.\n\nNapaka: {error}\n\nDogodek: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "discardDraftConfirmationDialogTitle": "Želite zavreči sporočilo, ki ga pišete?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForEditConfirmationDialogMessage": "Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetSearchHintSomeSelected": "Dodajte še enega uporabnika…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "unpinnedSubscriptionsLabel": "Nepripeto", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "messageListGroupYouWithYourself": "Sporočila sebi", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "errorRequestFailed": "Omrežna zahteva je spodletela: Stanje HTTP {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "Vnesite veljaven URL.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorNotificationOpenTitle": "Obvestila ni bilo mogoče odpreti", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "pinnedSubscriptionsLabel": "Pripeto", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "initialAnchorSettingTitle": "Odpri tok sporočil pri", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Prvo neprebrano sporočilo", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "Naj se sporočila ob pomikanju samodejno označijo kot prebrana?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogLinkText": "Preberite objavo na blogu!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingNewestAlways": "Najnovejše sporočilo", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingAlways": "Vedno", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Samo v pogledih pogovorov", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "initialAnchorSettingFirstUnreadConversations": "Prvo neprebrano v pogovorih, najnovejše drugje", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingTitle": "Ob pomikanju označi sporočila kot prebrana", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogTitle": "Dobrodošli v novi aplikaciji Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "markReadOnScrollSettingConversationsDescription": "Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Nikoli", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogMessage": "Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Začnimo", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "actionSheetOptionQuoteMessage": "Citiraj sporočilo", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + } +} diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index b851404ad8..6a6c217cd6 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -7,7 +7,7 @@ "@actionSheetOptionFollowTopic": { "description": "Label for following a topic on action sheet." }, - "actionSheetOptionUnstarMessage": "Зняти позначку зірочки з повідомлення", + "actionSheetOptionUnstarMessage": "Зняти позначку зірки з повідомлення", "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, @@ -51,11 +51,11 @@ "@errorSharingFailed": { "description": "Error message when sharing a message failed." }, - "errorStarMessageFailedTitle": "Не вдалося позначити повідомлення зірочкою", + "errorStarMessageFailedTitle": "Не вдалося позначити повідомлення зіркою", "@errorStarMessageFailedTitle": { "description": "Error title when starring a message failed." }, - "errorUnstarMessageFailedTitle": "Не вдалося зняти позначку зірочки з повідомлення", + "errorUnstarMessageFailedTitle": "Не вдалося зняти позначку зірки з повідомлення", "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, @@ -157,7 +157,7 @@ "@permissionsDeniedReadExternalStorage": { "description": "Message for dialog asking the user to grant permissions for external storage read access." }, - "actionSheetOptionStarMessage": "Позначити повідомлення зірочкою", + "actionSheetOptionStarMessage": "Вибрати повідомлення", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." }, @@ -173,10 +173,6 @@ "@errorResolveTopicFailedTitle": { "description": "Error title when marking a topic as resolved failed." }, - "actionSheetOptionQuoteAndReply": "Цитата і відповідь", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "signInWithFoo": "Увійти з {method}", "@signInWithFoo": { "description": "Button to use {method} to sign in to the app.", @@ -315,7 +311,7 @@ } } }, - "composeBoxGroupDmContentHint": "Група повідомлень", + "composeBoxGroupDmContentHint": "Написати групі", "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." }, @@ -473,7 +469,7 @@ "@errorWebAuthOperationalError": { "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "errorCouldNotFetchMessageSource": "Не вдалося отримати джерело повідомлення", + "errorCouldNotFetchMessageSource": "Не вдалося отримати джерело повідомлення.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -619,7 +615,7 @@ } } }, - "errorInvalidResponse": "Сервер надіслав недійсну відповідь", + "errorInvalidResponse": "Сервер надіслав недійсну відповідь.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -637,7 +633,7 @@ } } }, - "errorVideoPlayerFailed": "Неможливо відтворити відео", + "errorVideoPlayerFailed": "Неможливо відтворити відео.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -691,7 +687,7 @@ "@mentionsPageTitle": { "description": "Page title for the 'Mentions' message view." }, - "starredMessagesPageTitle": "Повідомлення, позначені зірочкою", + "starredMessagesPageTitle": "Вибрані повідомлення", "@starredMessagesPageTitle": { "description": "Page title for the 'Starred messages' message view." }, @@ -955,14 +951,10 @@ "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." }, - "combinedFeedPageTitle": "Комбінована стрічка", + "combinedFeedPageTitle": "Об'єднана стрічка", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." }, - "subscriptionListNoChannels": "Канали не знайдено", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "reactedEmojiSelfUser": "Ви", "@reactedEmojiSelfUser": { "description": "Display name for the user themself, to show on an emoji reaction added by the user." @@ -991,10 +983,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" @@ -1006,5 +994,203 @@ "emojiReactionsMore": "більше", "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." + }, + "newDmSheetSearchHintEmpty": "Додати користувачів", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Додати ще…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "newDmSheetComposeButtonLabel": "Написати", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "channelsEmptyPlaceholder": "Ви ще не підписані на жодний канал.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "inboxEmptyPlaceholder": "Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "actionSheetOptionListOfTopics": "Список тем", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "composeBoxBannerButtonCancel": "Відміна", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' 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." + }, + "topicsButtonLabel": "ТЕМИ", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionHideMutedMessage": "Сховати заглушене повідомлення", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "composeBoxBannerLabelEditMessage": "Редагування повідомлення", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "discardDraftForEditConfirmationDialogMessage": "При редагуванні повідомлення, текст з поля для редагування видаляється.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetScreenTitle": "Нове особисте повідомлення", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Нове особисте повідомлення", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetNoUsersFound": "Користувачі не знайдені", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "revealButtonLabel": "Показати повідомлення заглушеного відправника", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "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.)" + }, + "errorNotificationOpenAccountNotFound": "Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "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." + }, + "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." + }, + "editAlreadyInProgressMessage": "Редагування уже виконується. Дочекайтеся його завершення.", + "@editAlreadyInProgressMessage": { + "description": "Error message 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." + }, + "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" + } + } + }, + "mutedUser": "Заглушений користувач", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "initialAnchorSettingFirstUnreadAlways": "Перше непрочитане повідомлення", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "upgradeWelcomeDialogTitle": "Ласкаво просимо у новий додаток Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Ознайомтесь з анонсом у блозі!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Ходімо!", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingTitle": "Де відкривати стрічку повідомлень", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Найновіше повідомлення", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "upgradeWelcomeDialogMessage": "Ви знайдете звичні можливості у більш швидкому і легкому додатку.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingDescription": "Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "При прокручуванні повідомлень автоматично відмічати їх як прочитані?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Ніколи", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Тільки при перегляді бесід", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Завжди", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingTitle": "Відмічати повідомлення як прочитані при прокручуванні", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionQuoteMessage": "Цитувати повідомлення", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "discardDraftForOutboxConfirmationDialogMessage": "При відновленні невідправленого повідомлення, вміст поля редагування очищається.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "markReadOnScrollSettingConversationsDescription": "Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." } } 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..806f96eda9 --- /dev/null +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -0,0 +1,1194 @@ +{ + "settingsPageTitle": "设置", + "@settingsPageTitle": {}, + "actionSheetOptionResolveTopic": "标记为已解决", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "aboutPageTitle": "关于 Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "应用程序版本", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "开源许可", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "选择账号", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "switchAccountButton": "切换账号", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountButton": "尝试另一个账号", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "登出", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "登出?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "添加一个账号", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "发送私信", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "errorCouldNotShowUserProfile": "无法显示用户个人资料。", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededOpenSettings": "打开设置", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "标记频道为已读", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "话题列表", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionFollowTopic": "关注话题", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionUnfollowTopic": "取消关注话题", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageTapToView": "查看更多", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "tryAnotherAccountMessage": "您在 {url} 的账号加载时间过长。", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "logOutConfirmationDialogMessage": "下次登入此账号时,您将需要重新输入组织网址和账号信息。", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "登出", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "permissionsNeededTitle": "需要额外权限", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsDeniedCameraAccess": "上传图片前,请在设置授予 Zulip 相应的权限。", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "permissionsDeniedReadExternalStorage": "上传文件前,请在设置授予 Zulip 相应的权限。", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "newDmSheetComposeButtonLabel": "撰写消息", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "composeBoxChannelContentHint": "发送消息到 {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "准备编辑消息…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "发送", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(未知频道)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxLoadingMessage": "(加载消息 {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "unknownUserName": "(未知用户)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dmsWithOthersPageTitle": "与{others}的私信", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "messageListGroupYouWithYourself": "与自己的私信", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "contentValidationErrorTooLong": "消息的长度不能超过10000个字符。", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "errorDialogLearnMore": "更多信息", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "errorDialogContinue": "好的", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "错误", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "lightboxCopyLinkTooltip": "复制链接", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "lightboxVideoCurrentPosition": "当前进度", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "视频时长", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginFormSubmitLabel": "登入", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "loginMethodDivider": "或", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "signInWithFoo": "使用{method}登入", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginAddAnAccountPageTitle": "添加账号", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "loginServerUrlLabel": "Zulip 服务器网址", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "隐藏密码", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "topicValidationErrorMandatoryButEmpty": "话题在该组织为必填项。", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorInvalidApiKeyMessage": "您在 {url} 的账号无法被登入。请重试或者使用另外的账号。", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "服务器的回复不合法。", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorNetworkRequestFailed": "网络请求失败", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "errorMalformedResponse": "服务器的回复不合法;HTTP 状态码 {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorMalformedResponseWithCause": "服务器的回复不合法;HTTP 状态码 {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "请输入正确的网址。", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "serverUrlValidationErrorNoUseEmail": "请输入服务器网址,而不是您的电子邮件。", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "spoilerDefaultHeaderText": "剧透", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAllAsReadLabel": "将所有消息标为已读", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "markAsReadComplete": "已将 {num, plural, other{{num} 条消息}}标为已读。", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsUnreadInProgress": "正在将消息标为未读…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "未能将消息标为未读", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "userRoleAdministrator": "管理员", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleModerator": "版主", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleMember": "成员", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "recentDmConversationsEmptyPlaceholder": "您还没有任何私信消息!何不开启一个新对话?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "channelsEmptyPlaceholder": "您还没有订阅任何频道。", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "channelFeedButtonTooltip": "频道订阅", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "unpinnedSubscriptionsLabel": "未置顶", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "notifGroupDmConversationLabel": "{senderFullName}向您和其他 {numOthers, plural, other{{numOthers} 个用户}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "wildcardMentionChannel": "频道", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionEveryone": "所有人", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "频道", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "话题", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopicDescription": "通知话题", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "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.)" + }, + "messageIsEditedLabel": "已编辑", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "暗色", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "浅色", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "pollVoterNames": "({voterNames})", + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "type": "String", + "example": "Alice, Bob, Chad" + } + } + }, + "pollWidgetOptionsMissing": "该投票还没有任何选项。", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "experimentalFeatureSettingsWarning": "以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "initialAnchorSettingDescription": "您可以将消息的起始位置设置为第一条未读消息或者最新消息。", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "设置消息起始位置于", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "最新消息", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "在单个话题或私信的第一条未读消息;在其他情况下的最新消息", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "第一条未读消息", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionCopyMessageLink": "复制消息链接", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "errorConnectingToServerDetails": "未能连接到在 {serverUrl} 的 Zulip 服务器。即将重连:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorAccountLoggedIn": "在 {server} 的账号 {email} 已经在您的账号列表了。", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorServerVersionUnsupportedMessage": "{url} 运行的 Zulip 服务器版本 {zulipVersion} 过低。该客户端只支持 {minSupportedZulipVersion} 及以后的服务器版本。", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "errorHandlingEventDetails": "处理来自 {serverUrl} 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:{error}\n\n事件:{event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "editAlreadyInProgressTitle": "未能编辑消息", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "errorServerMessage": "服务器:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "loginErrorMissingUsername": "请输入用户名。", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "successMessageTextCopied": "已复制消息文本", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "errorBannerDeactivatedDmLabel": "您不能向被停用的用户发送消息。", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "noEarlierMessages": "没有更早的消息了", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "discardDraftForOutboxConfirmationDialogMessage": "当您恢复未能发送的消息时,文本框已有的内容将会被清空。", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "loginPageTitle": "登入", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginEmailLabel": "电子邮箱地址", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "topicValidationErrorTooLong": "话题长度不应该超过 60 个字符。", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "userRoleUnknown": "未知", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "markAsReadInProgress": "正在将消息标为已读…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "onePersonTyping": "{typist}正在输入…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "inboxPageTitle": "收件箱", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "openLinksWithInAppBrowser": "使用内置浏览器打开链接", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "inboxEmptyPlaceholder": "您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "themeSettingSystem": "系统", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "experimentalFeatureSettingsPageTitle": "实验功能", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "wildcardMentionChannelDescription": "通知频道", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "actionSheetOptionMuteTopic": "静音话题", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "取消静音话题", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionUnresolveTopic": "标记为未解决", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorUnresolveTopicFailedTitle": "未能将话题标记为未解决", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "errorResolveTopicFailedTitle": "未能将话题标记为解决", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "actionSheetOptionCopyMessageText": "复制消息文本", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "从这里标为未读", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionShare": "分享", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "errorLoginInvalidInputTitle": "输入的信息不正确", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorMessageNotSent": "未能发送消息", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorCouldNotConnectTitle": "未能连接", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "找不到此消息。", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "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." + }, + "composeBoxGroupDmContentHint": "发送私信到群组", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxSelfDmContentHint": "向自己撰写消息", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxUploadingFilename": "正在上传 {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxTopicHintText": "话题", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxEnterTopicOrSkipHintText": "输入话题(默认为“{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "messageListGroupYouAndOthers": "您和{others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dialogCancel": "取消", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "loginUsernameLabel": "用户名", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "errorRequestFailed": "网络请求失败;HTTP 状态码 {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "errorVideoPlayerFailed": "未能播放视频。", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "combinedFeedPageTitle": "综合消息", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "channelsPageTitle": "频道", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "pinnedSubscriptionsLabel": "置顶", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "twoPeopleTyping": "{typist}和{otherTypist}正在输入…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "多个用户正在输入…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "errorNotificationOpenTitle": "未能打开消息提醒", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionAddingFailedTitle": "未能添加表情符号", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "未能移除表情符号", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "actionSheetOptionHideMutedMessage": "再次隐藏静音消息", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionStarMessage": "添加星标消息标记", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "取消星标消息标记", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "编辑消息", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "将话题标为已读", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "出现了一些问题", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "已经登入该账号", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorWebAuthOperationalError": "发生了未知的错误。", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorCouldNotFetchMessageSource": "未能获取原始消息。", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorCopyingFailed": "未能复制消息文本", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "errorFailedToUploadFileTitle": "未能上传文件:{filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "type": "String", + "example": "foo.txt" + }, + "size": { + "type": "String", + "example": "20.2" + } + } + }, + "errorFilesTooLargeTitle": "文件过大", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorLoginFailedTitle": "未能登入", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageEditNotSaved": "未能保存消息编辑", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorLoginCouldNotConnect": "未能连接到服务器:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorQuotationFailed": "未能引用消息", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorConnectingToServerShort": "未能连接到 Zulip. 重试中…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorFilesTooLarge": "{num, plural, other{{num} 个您上传的文件}}大小超过了该组织 {maxFileUploadSizeMib} MiB 的限制:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "errorHandlingEventTitle": "处理 Zulip 事件时发生了一些问题。即将重连…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorCouldNotOpenLinkTitle": "未能打开链接", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorCouldNotOpenLink": "未能打开此链接:{url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorFollowTopicFailed": "未能关注话题", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorMuteTopicFailed": "未能静音话题", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorUnmuteTopicFailed": "未能取消静音话题", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorUnfollowTopicFailed": "未能取消关注话题", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "分享失败", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "未能添加星标消息标记", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnstarMessageFailedTitle": "未能取消星标消息标记", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotEditMessageTitle": "未能编辑消息", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "successLinkCopied": "已复制链接", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageLinkCopied": "已复制消息链接", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerCannotPostInChannelLabel": "您没有足够的权限在此频道发送消息。", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxBannerLabelEditMessage": "编辑消息", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "取消", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "保存", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in 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." + }, + "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.)" + }, + "discardDraftForEditConfirmationDialogMessage": "当您编辑消息时,文本框中已有的内容将会被清空。", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "composeBoxAttachFilesTooltip": "上传文件", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "上传图片或视频", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "拍摄照片", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "撰写消息", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetSearchHintEmpty": "添加一个或多个用户", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetScreenTitle": "发起私信", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "发起私信", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintSomeSelected": "添加更多用户…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "newDmSheetNoUsersFound": "没有用户", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "发送私信给 @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "dmsWithYourselfPageTitle": "与自己的私信", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "contentValidationErrorUploadInProgress": "请等待上传完成。", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "contentValidationErrorEmpty": "发送的消息不能为空!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "contentValidationErrorQuoteAndReplyInProgress": "请等待引用消息完成。", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "dialogContinue": "继续", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "dialogClose": "关闭", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "snackBarDetails": "详情", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "loginErrorMissingEmail": "请输入电子邮箱地址。", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "密码", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingPassword": "请输入密码。", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "serverUrlValidationErrorEmpty": "请输入网址。", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "serverUrlValidationErrorUnsupportedScheme": "服务器网址必须以 http:// 或 https:// 开头。", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "errorMarkAsReadFailedTitle": "未能将消息标为已读", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadComplete": "已将 {num, plural, other{{num} 条消息}}标为未读。", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "today": "今天", + "@today": { + "description": "Term to use to reference the current day." + }, + "yesterday": "昨天", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "所有者", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleGuest": "访客", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "recentDmConversationsSectionHeader": "私信", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "recentDmConversationsPageTitle": "私信", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "mentionsPageTitle": "被提及消息", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "starredMessagesPageTitle": "星标消息", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "mainMenuMyProfile": "个人资料", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "话题", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "notifSelfUser": "您", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "您", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "wildcardMentionAll": "所有人", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionAllDmDescription": "通知收件人", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "messageIsMovedLabel": "已移动", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingTitle": "主题", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "emojiReactionsMore": "更多", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "errorNotificationOpenAccountNotFound": "未能找到关联该消息提醒的账号。", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "emojiPickerSearchEmoji": "搜索表情符号", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "scrollToBottomTooltip": "拖动到最底", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "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." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "wildcardMentionStreamDescription": "通知频道", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "pollWidgetQuestionMissing": "无问题。", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "markReadOnScrollSettingAlways": "总是", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "从不", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "只在对话视图", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "在滑动浏览消息时,是否自动将它们标记为已读?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "只将在同一个话题或私聊中的消息自动标记为已读。", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingTitle": "滑动时将消息标为已读", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionQuoteMessage": "引用消息", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "upgradeWelcomeDialogTitle": "欢迎来到新的 Zulip 应用!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "开始吧", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "来看看最新的公告吧!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "您将会得到到更快,更流畅的体验。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + } +} diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb new file mode 100644 index 0000000000..651a3d15f8 --- /dev/null +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -0,0 +1,590 @@ +{ + "settingsPageTitle": "設定", + "@settingsPageTitle": {}, + "aboutPageTitle": "關於 Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "chooseAccountPageLogOutButton": "登出", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "tryAnotherAccountMessage": "你在 {url} 的帳號載入的比較久", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "chooseAccountPageTitle": "選取帳號", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "aboutPageAppVersion": "App 版本", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "switchAccountButton": "切換帳號", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "actionSheetOptionListOfTopics": "話題列表", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMuteTopic": "靜音話題", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "標註為已解決", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "tryAnotherAccountButton": "請嘗試別的帳號", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "aboutPageTapToView": "點選查看", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "aboutPageOpenSourceLicenses": "開源授權條款", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "logOutConfirmationDialogTitle": "登出?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "登出", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "profileButtonSendDirectMessage": "發送私訊", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "chooseAccountButtonAddAnAccount": "增添帳號", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "permissionsNeededTitle": "需要的權限", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "開啟設定", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "標註頻道為已讀", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionUnmuteTopic": "取消靜音話題", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionUnresolveTopic": "標註為未解決", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "無法標註話題為已解決", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "無法標註話題為未解決", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageText": "複製訊息文字", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "複製訊息連結", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "從這裡開始標註為未讀", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "標註話題為已讀", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "actionSheetOptionShare": "分享", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionStarMessage": "收藏訊息", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "取消收藏訊息", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "編輯訊息", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorWebAuthOperationalErrorTitle": "出錯了", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "出現了意外的錯誤。", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "帳號已經登入了", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorAccountLoggedIn": "在 {server} 的帳號 {email} 已經存在帳號清單中。", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "initialAnchorSettingFirstUnreadAlways": "第一則未讀訊息", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionUnfollowTopic": "取消跟隨話題", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "errorUnmuteTopicFailed": "無法取消靜音話題", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorMuteTopicFailed": "無法靜音話題", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorUnstarMessageFailedTitle": "無法取消收藏訊息", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "successLinkCopied": "已複製連結", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageLinkCopied": "已複製訊息連結", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "composeBoxBannerButtonCancel": "取消", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxAttachMediaTooltip": "附加圖片或影片", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "loginPageTitle": "登入", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginHidePassword": "隱藏密碼", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "loginErrorMissingUsername": "請輸入您的使用者名稱。", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "userRoleMember": "成員", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "wildcardMentionTopic": "topic", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "emojiPickerSearchEmoji": "搜尋表情符號", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "actionSheetOptionFollowTopic": "跟隨話題", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "errorUnfollowTopicFailed": "無法取消跟隨話題", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorStarMessageFailedTitle": "無法收藏訊息", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "editAlreadyInProgressTitle": "無法編輯訊息", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "errorCouldNotEditMessageTitle": "無法編輯訊息", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxGroupDmContentHint": "訊息群組", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "訊息 {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "errorDialogLearnMore": "了解更多", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "loginEmailLabel": "電子郵件地址", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "markAllAsReadLabel": "標註所有訊息為已讀", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "wildcardMentionChannel": "channel", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "themeSettingDark": "深色主題", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingSystem": "系統主題", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "actionSheetOptionHideMutedMessage": "再次隱藏已靜音的話題", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorQuotationFailed": "引述失敗", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "successMessageTextCopied": "已複製訊息文字", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "composeBoxBannerLabelEditMessage": "編輯訊息", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxAttachFilesTooltip": "附加檔案", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "newDmSheetScreenTitle": "新增私訊", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "新增私訊", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "dialogCancel": "取消", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "dialogContinue": "繼續", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "loginFormSubmitLabel": "登入", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "signInWithFoo": "使用 {method} 登入", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginPasswordLabel": "密碼", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginServerUrlLabel": "您的 Zulip 伺服器網址", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginUsernameLabel": "使用者名稱", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "yesterday": "昨天", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "擁有者", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "管理員", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleModerator": "版主", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "errorFollowTopicFailed": "無法跟隨話題", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "actionSheetOptionQuoteMessage": "引述訊息", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "recentDmConversationsPageTitle": "私人訊息", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "composeBoxTopicHintText": "話題", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "today": "今天", + "@today": { + "description": "Term to use to reference the current day." + }, + "channelsPageTitle": "頻道", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "loginErrorMissingPassword": "請輸入您的密碼。", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "userRoleGuest": "訪客", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "mentionsPageTitle": "提及", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "recentDmConversationsSectionHeader": "私人訊息", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "composeBoxDmContentHint": "訊息 @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "dialogClose": "關閉", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "loginErrorMissingEmail": "請輸入您的電子郵件地址。", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "lightboxCopyLinkTooltip": "複製連結", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "composeBoxUploadingFilename": "正在上傳 {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "topicsButtonLabel": "話題", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingLight": "淺色主題", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingTitle": "主題", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "errorVideoPlayerFailed": "無法播放影片。", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "errorDialogTitle": "錯誤", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "wildcardMentionChannelDescription": "通知頻道", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "upgradeWelcomeDialogTitle": "歡迎使用新 Zulip 應用程式!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "errorCouldNotOpenLinkTitle": "無法開啟連結", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "emojiReactionsMore": "更多", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "errorSharingFailed": "分享失敗。", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "contentValidationErrorUploadInProgress": "請等待上傳完成。", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "newDmSheetSearchHintEmpty": "增添一個或多個使用者", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "contentValidationErrorQuoteAndReplyInProgress": "請等待引述完成。", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorLoginFailedTitle": "登入失敗", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorNetworkRequestFailed": "網路請求失敗", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "serverUrlValidationErrorInvalidUrl": "請輸入有效的網址。", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorCopyingFailed": "複製失敗", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "serverUrlValidationErrorNoUseEmail": "請輸入伺服器網址,而非您的電子郵件。", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorEmpty": "請輸入網址。", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "errorMessageDoesNotSeemToExist": "該訊息似乎不存在。", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorCouldNotOpenLink": "無法開啟連結: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "spoilerDefaultHeaderText": "劇透", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsUnreadInProgress": "正在標註訊息為未讀…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorLoginCouldNotConnect": "無法連線到伺服器:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "無法連線", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorInvalidResponse": "伺服器傳送了無效的請求。", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "newDmSheetSearchHintSomeSelected": "增添其他使用者…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "inboxPageTitle": "收件匣", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "initialAnchorSettingNewestAlways": "最新訊息", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "experimentalFeatureSettingsPageTitle": "實驗性功能", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "errorNotificationOpenTitle": "無法開啟通知", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "loginAddAnAccountPageTitle": "增添帳號", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "channelFeedButtonTooltip": "頻道饋給", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "unpinnedSubscriptionsLabel": "未釘選", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "wildcardMentionTopicDescription": "通知話題", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "pinnedSubscriptionsLabel": "已釘選", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "mutedUser": "已靜音的使用者", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "combinedFeedPageTitle": "綜合饋給", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + } +} diff --git a/docs/changelog.md b/docs/changelog.md index 28807677f0..3bcb666b5b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,340 @@ ## Unreleased +## 30.0.259 (2025-06-23) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +New since last week's release: +* The app shows others' availability. (#196) +* When you're using the app, you'll appear to others + as online, according to your settings. (#1607) +* Much broader TeX math support. (PR #1601) +* More translation updates. (PR #1615) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for developers + +* Resolved in main: PR #1598, PR #1599, #196, #1607, PR #1615 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * yet further toward #46 via PR #1601 (cherry-picked) + * #296 via PR #1561 + + +## 30.0.258 (2025-06-16) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs previous beta, v30.0.257) + +* More translation updates. (PR #1596) +* Handle additional error cases in migrating data from + legacy app. (PR #1595) + + +### Highlights for developers + +* User-visible changes not described above: + * Tweak wording of first-unread setting. (PR #1597) + +* Resolved in main: #1070, #1580, PR #1595, PR #1596, PR #1597 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 30.0.257 (2025-06-15) + +This was a beta-only release. + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs previous alpha, v30.0.256) + +* Translation updates, including near-complete translations + for German (de) and Italian (it). + + +### Highlights for developers + +* User-visible changes not described above: + * Updated link in welcome dialog. (part of #1580) + * Skip ackedPushToken in migrated account data. + (part of #1070) + +* Resolved in main: #1537, #1582 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1070 via PR #1588 + * #1580 via PR #1590 + + +## 30.0.256 (2025-06-15) + +With this release, this new app takes on the identity +of the main Zulip app! + +This was an alpha-only release. + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs last beta, v0.0.33) + +* This app now uses the app ID of the main Zulip mobile app, + formerly used by the legacy app. It therefore installs over + any previous install of the legacy app, rather than of the + Flutter beta app. (#1582) +* The app's icon and name no longer say "beta". (#1537) +* Migrate accounts and settings from the legacy app's data. (#1070) +* Show welcome dialog on upgrading from legacy app. (#1580) + + +### Highlights for developers + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1537 via PR #1577 + * #1582 via PR #1586 + * #1070 via PR #1588 + * #1580 via PR #1590 + + +## 0.0.33 (2025-06-13) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* Messages are automatically marked read as you scroll through + a conversation. (#81) +* More translations. + + +### Highlights for developers + +* User-visible changes not described above: + * "Quote message" button label rather than "Quote and reply" + (PR #1575) + +* Resolved in main: PR #1575, #81 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 0.0.32 (2025-06-12) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* The keyboard opens immediately when you start a + new conversation. (#1543) +* Translation updates, including new near-complete translations + for Slovenian (sl) and Chinese (Simplified, China) (zh_Hans_CN). +* Several small improvements to the newest features: + muted users (#296), message links going directly to message (#82). + + +### Highlights for developers + +* User-visible changes not described above: + * upgraded Flutter and deps (PR #1568) + * suppress long-press on muted-sender message, + and hide muted users in new-DM list (part of #296) + * reject internal links with malformed /near/ operands + (part of #82) + +* Resolved in main: #276 (though external to the tree), + #1543, #82, #80, #1147, #1441 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 0.0.31 (2025-06-11) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* Conversations open at your first unread message. (#80) +* TeX support now enabled by default, and covers a larger + set of expressions. More to come later. (#46) +* Numerous small improvements to the newest features: + muted users (#296), start a DM thread (#127), + recover failed send (#1441), open mid-history (#82). + + +### Highlights for developers + +* Resolved in main: #1540, #385, #386, #127 + +* Resolved in the experimental branch: + * #82 via PR #1566 + * #80 via PR #1517 + * #1441 via PR #1453 + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #1147 via PR #1379 + * #296 via PR #1561 + + +## 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 +not yet merged to the main branch. + + +### Highlights for users + +This is a feature-packed release, as this new app gets near ready to +replace the legacy Zulip mobile app a few weeks from now. +Please try out the new features, and as always report anything broken. + +* Initial support for TeX math! Try enabling the + experimental flag, in settings. (#46) +* Edit a message. (#126) +* Initial support to open at first unread message; + try enabling in settings. (#80) +* List of topics in channel. (#1158) +* (iOS) Go to conversation on opening notification. (#1147) + + +### Highlights for developers + +* Further user highlights that didn't fit in 500 characters: + * #1441 simplified local echo, enabling recovery from failed send + * #82 on following a message link, go to specific message + in middle of history + * #930 no more images moving around when you navigate from + one message list to another + * #1250 general chat + * #1470 when you re-open the app after a while and start typing + a message, your draft is preserved across the app's reloading + its data from the server + +* Resolved in main: #1470, #407, #1485, #930, #44, #1250, #126 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #1158 via PR #1500 + * #1495 via PR #1506 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + + ## 0.0.28 (2025-04-21) ### Highlights for users diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..4ec1d6090a --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,300 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client-side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +the Apple Push Notification service to show notifications on iOS +Simulator. + + +### Contents + +* [Trigger a notification on the iOS Simulator](#trigger-notification) +* [Canned APNs payloads](#canned-payloads) +* [Produce sample APNs payloads](#produce-payload) + + +
+ +## Trigger a notification on the iOS Simulator + +The iOS Simulator permits delivering a notification payload +artificially, as if APNs had delivered it to the device, +but without actually involving APNs or any other server. + +As input for this operation, you'll need an APNs payload, +i.e. a JSON blob representing what APNs might deliver to the app +for a notification. + +To get an APNs payload, you can generate one from a Zulip dev server +by following the [instructions in a section below](#produce-payload), +or you can use one of the payloads included +in this document [below](#canned-payloads). + + +### 1. Determine the device ID of the iOS Simulator + +To receive a notification on the iOS Simulator, we need to first +determine the device ID of the iOS Simulator, to specify which +Simulator instance we want to push the payload to. + +```shell-session +$ xcrun simctl list devices booted +``` + +
+Example output: + +```shell-session +$ xcrun simctl list devices booted +== Devices == +-- iOS 18.3 -- + iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted) +``` + +
+ + +### 2. Trigger a notification by pushing the payload to the iOS Simulator + +By running the following command with a valid APNs payload, you should +receive a notification on the iOS Simulator for the zulip-flutter app. +Tapping on the notification should route to the respective conversation. + +```shell-session +$ xcrun simctl push [device-id] org.zulip.Zulip [payload json path] +``` + +
+Example output: + +```shell-session +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C org.zulip.Zulip ./dm.json +Notification sent to 'org.zulip.Zulip' +``` + +
+ + +
+ +## Canned APNs payloads + +The following pre-canned APNs payloads can be used in case you don't +have one. + +These canned payloads were generated from +Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, +in April 2025. +The `user_id` is that of `iago@zulip.com` in the Zulip dev environment. + +These canned payloads assume that EXTERNAL_HOST has its default value +for the dev server. If you've +[set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) +in order to enable your device to connect to the dev server, you'll +need to adjust the `realm_url` fields. You can do this by a +find-and-replace for `localhost`; for example, +`perl -i -0pe s/localhost/10.0.2.2/g tmp/*.json` after saving the +canned payloads to files `tmp/*.json`. + +
+Payload: dm.json + +```json +{ + "aps": { + "alert": { + "title": "Zoe", + "subtitle": "", + "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?" + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 7, + "sender_email": "user7@zulipdev.com", + "time": 1740890583, + "recipient_type": "private", + "message_ids": [ + 87 + ] + } +} +``` + +
+ +
+Payload: group_dm.json + +```json +{ + "aps": { + "alert": { + "title": "Othello, the Moor of Venice, Polonius (guest), Iago", + "subtitle": "Othello, the Moor of Venice:", + "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 12, + "sender_email": "user12@zulipdev.com", + "time": 1740533641, + "recipient_type": "private", + "pm_users": "11,12,13", + "message_ids": [ + 17 + ] + } +} +``` + +
+ +
+Payload: stream.json + +```json +{ + "aps": { + "alert": { + "title": "#devel > plotter", + "subtitle": "Desdemona:", + "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 9, + "sender_email": "user9@zulipdev.com", + "time": 1740558997, + "recipient_type": "stream", + "stream": "devel", + "stream_id": 11, + "topic": "plotter", + "message_ids": [ + 40 + ] + } +} +``` + +
+ + +
+ +## Produce sample APNs payloads + +### 1. Set up dev server + +To set up and run the dev server on the same Mac machine that hosts +the iOS Simulator, follow Zulip's +[standard instructions](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +for setting up a dev server. + +If you want to run the dev server on a different machine than the Mac +host, you'll need to follow extra steps +[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md) +to make it possible for the app running on the iOS Simulator to +connect to the dev server. + + +### 2. Set up the dev user to receive mobile notifications. + +We'll use the devlogin user `iago@zulip.com` to test notifications. +Log in to that user by going to `/devlogin` on that server on Web. + +Then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + + +### 3. Log in as the dev user on zulip-flutter. + + + +To log in as this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then log in as the dev user +(`iago@zulip.com`). + + +### 4. Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the payload in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload).decode()) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + + +### 5. Send messages to the dev user + +To generate notifications to the dev user `iago@zulip.com` we need to +send messages from another user. For a variety of different types of +payloads try sending a message in a topic, a message in a group DM, +and one in one-one DM. Then look for the payloads in the server logs +by searching for "APNS payload". + + +### 6. Transform and save the payload to a file + +The payload JSON recorded in the steps above is in the form the +Zulip server sends to the bouncer. The bouncer restructures this +slightly to produce the actual payload which it sends to APNs, +and which APNs delivers to the app on the device. +To apply the same restructuring, run the payload through +the following `jq` command: + +```shell-session +$ echo '{"alert":{"title": ...' \ + | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \ + > payload.json +``` diff --git a/docs/release.md b/docs/release.md index 7895ba50b0..2d1b40e641 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,13 +1,29 @@ # Making releases +## NOTE: This document is out of date. + +Now that this is the main Zulip mobile app, the actual release process +is roughly a hybrid of the steps below for building the app, +then the steps from the legacy app's release instructions for +distributing the app. + +Revising this into a single coherent set of instructions +is an open TODO. + + ## Prepare source tree * If we haven't recently (like in the last week) upgraded our Flutter and packages dependencies, do that first. For details of how, see our README. -* Update translations from Weblate. - See `git log --stat --grep eblate` for previous examples. +* Update translations from Weblate: + * Run the [GitHub action][weblate-github-action] to create a PR + (or update an existing bot PR) with translation updates. + * CI doesn't run on the bot's PRs. So if you suspect the PR might + break anything (e.g. if this is the first sync since changing + something in our Weblate setup), run `tools/check` on it yourself. + * Merge the PR. * Write an entry in `docs/changelog.md`, under "Unreleased". Commit that change. @@ -15,6 +31,8 @@ * Run `tools/bump-version` to update the version number. Inspect the resulting commit and tag, and push. +[weblate-github-action]: https://github.com/zulip/zulip-flutter/actions/workflows/update-translations.yml + ## Build and upload alpha: Android @@ -146,6 +164,38 @@ "f123". This efficiently finds any threads that mentioned "#F123". +## Preview releases + +Sometimes we make a release that includes some experimental changes +not yet merged to the `main` branch, i.e. a "preview release". + +Steps specific to this type of release are: + +* To prepare the tree, start from main and use commands like + `git merge --no-ff pr/123456` to merge together the desired PRs. + + The use of `--no-ff` ensures that each such step creates an actual + merge commit. This is helpful because it means that a command like + `git log --first-parent --oneline origin..` + can print a list of exactly which PRs were included, by number. + That record is useful for understanding the relationship between + releases, and for re-creating a similar branch with updated versions + of the same PRs. + +* The changelog should distinguish, outside the "for users" section, + between changes in main and changes not yet in main. + See past examples; search for "experimental". + +* After the new release is uploaded, the changelog and version number + in main should be updated to match the new release. + + Try `git checkout -p v12.34.567 docs/changelog.md pubspec.yaml`. + Use the `-p` prompt to skip any other pubspec updates, such as + dependencies. Then + `git commit -am "version: Sync version and changelog from v12.34.567 release"` + (with the correct version number), and push. + + ## One-time or annual setup * You'll need the Google Play upload key. The setup is similar to diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c0db288bed..009b8fb7c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,64 +37,64 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - Firebase/Messaging (11.10.0): + - Firebase/CoreOnly (11.13.0): + - FirebaseCore (~> 11.13.0) + - Firebase/Messaging (11.13.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (= 11.10.0) + - FirebaseMessaging (~> 11.13.0) + - firebase_core (3.14.0): + - Firebase/CoreOnly (= 11.13.0) - Flutter - - firebase_messaging (15.2.5): - - Firebase/Messaging (= 11.10.0) + - firebase_messaging (15.2.7): + - Firebase/Messaging (= 11.13.0) - firebase_core - Flutter - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.13.0): + - FirebaseCoreInternal (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.13.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.13.0): + - FirebaseCore (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.13.0): + - FirebaseCore (~> 11.13.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - Flutter (1.0.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - image_picker_ios (0.0.1): @@ -112,28 +112,28 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) - share_plus (0.0.1): - Flutter - - sqlite3 (3.49.1): - - sqlite3/common (= 3.49.1) - - sqlite3/common (3.49.1) - - sqlite3/dbstatvtab (3.49.1): + - sqlite3 (3.50.1): + - sqlite3/common (= 3.50.1) + - sqlite3/common (3.50.1) + - sqlite3/dbstatvtab (3.50.1): - sqlite3/common - - sqlite3/fts5 (3.49.1): + - sqlite3/fts5 (3.50.1): - sqlite3/common - - sqlite3/math (3.49.1): + - sqlite3/math (3.50.1): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/perf-threadsafe (3.50.1): - sqlite3/common - - sqlite3/rtree (3.49.1): + - sqlite3/rtree (3.50.1): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.1) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math @@ -220,31 +220,31 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_core: 2d4534e7b489907dcede540c835b48981d890943 - firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64 - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 + firebase_core: 700bac7ed92bb754fd70fbf01d72b36ecdd6d450 + firebase_messaging: 860c017fcfbb5e27c163062d1d3135388f3ef954 + FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 + FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c + FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 + FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: f29024626962457f3470184232766516dee8dfea share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 + sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 -PODFILE CHECKSUM: 7ed5116924b3be7e8fb75f7aada61e057028f5c7 +PODFILE CHECKSUM: 66b7725a92b85e7acc28f0ced0cdacebf18e1997 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..37f8231c8d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -48,6 +49,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -115,6 +117,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -297,6 +300,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -388,7 +392,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -518,7 +522,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -542,7 +546,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b636303481..eefed07cd6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,64 @@ import Flutter @main @objc class AppDelegate: FlutterAppDelegate { + private var notificationTapEventListener: NotificationTapEventListener? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + let controller = window?.rootViewController as! FlutterViewController + + // Retrieve the remote notification payload from launch options; + // this will be null if the launch wasn't triggered by a notification. + let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any] + let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) + NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!) + + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let userInfo = response.notification.request.content.userInfo + notificationTapEventListener!.onNotificationTapEvent(payload: userInfo) + } + completionHandler() + } +} + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} + +// Adapted from Pigeon's Swift example for @EventChannelApi: +// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable : Any]) { + eventSink?.success(NotificationTapEvent(payload: payload)) + } } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png index ad38ed7d1a..4d6b2fe976 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png index 3b32973ee5..aefc1dbb51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png index a3fc65a541..d25093ebd7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png index c365538aad..c9cb394ddd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png index 8bcbf35edc..fe7eb99eed 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png index aabaa797fd..07c53507e2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png index 5f8659a82e..da0fba9728 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png index 5f8659a82e..da0fba9728 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png index 2119bceba7..6dbeee644f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png index e4acc94f67..8694e5ff34 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png index 2c2470067f..a347e86acc 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5f5d9c5ea2..d86c7afca7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Zulip beta + Zulip CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Zulip beta + Zulip CFBundlePackageType APPL CFBundleShortVersionString @@ -26,7 +26,7 @@ CFBundleURLName - com.zulip.flutter + org.zulip.Zulip CFBundleURLSchemes zulip diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..82ac8128e4 --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,335 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNotifications(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNotifications(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNotifications(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNotifications(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNotifications(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNotifications(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationDataFromLaunch: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationDataFromLaunch? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationDataFromLaunch( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationDataFromLaunch, rhs: NotificationDataFromLaunch) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationTapEvent: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationTapEvent( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + case 130: + return NotificationTapEvent.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NotificationsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NotificationDataFromLaunch { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? NotificationTapEvent { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NotificationsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NotificationsPigeonCodecWriter(data: data) + } +} + +class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) +} + +var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter()); + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + func getNotificationDataFromLaunch() throws -> NotificationDataFromLaunch? +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NotificationHostApiSetup { + static var codec: FlutterStandardMessageCodec { NotificationsPigeonCodec.shared } + /// Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + let getNotificationDataFromLaunchChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getNotificationDataFromLaunchChannel.setMessageHandler { _, reply in + do { + let result = try api.getNotificationDataFromLaunch() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getNotificationDataFromLaunchChannel.setMessageHandler(nil) + } + } +} + +private class PigeonStreamHandler: NSObject, FlutterStreamHandler { + private let wrapper: PigeonEventChannelWrapper + private var pigeonSink: PigeonEventSink? = nil + + init(wrapper: PigeonEventChannelWrapper) { + self.wrapper = wrapper + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + pigeonSink = PigeonEventSink(events) + wrapper.onListen(withArguments: arguments, sink: pigeonSink!) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + pigeonSink = nil + wrapper.onCancel(withArguments: arguments) + return nil + } +} + +class PigeonEventChannelWrapper { + func onListen(withArguments arguments: Any?, sink: PigeonEventSink) {} + func onCancel(withArguments arguments: Any?) {} +} + +class PigeonEventSink { + private let sink: FlutterEventSink + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + + func success(_ value: ReturnType) { + sink(value) + } + + func error(code: String, message: String?, details: Any?) { + sink(FlutterError(code: code, message: message, details: details)) + } + + func endOfStream() { + sink(FlutterEndOfEventStream) + } + +} + +class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper { + static func register(with messenger: FlutterBinaryMessenger, + instanceName: String = "", + streamHandler: NotificationTapEventsStreamHandler) { + var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if !instanceName.isEmpty { + channelName += ".\(instanceName)" + } + let internalStreamHandler = PigeonStreamHandler(wrapper: streamHandler) + let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec) + channel.setStreamHandler(internalStreamHandler) + } +} + diff --git a/l10n.yaml b/l10n.yaml index 6d15a20096..563219f948 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,7 +1,6 @@ # Docs on this config file: # https://docs.flutter.dev/ui/accessibility-and-localization/internationalization#configuring-the-l10nyaml-file -synthetic-package: false arb-dir: assets/l10n output-dir: lib/generated/l10n template-arb-file: app_en.arb diff --git a/lib/api/core.dart b/lib/api/core.dart index fb8d564ae6..39101f1420 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -14,13 +14,14 @@ import 'exception.dart'; /// /// When updating this, also update [kMinSupportedZulipFeatureLevel] /// and the README. -const kMinSupportedZulipVersion = '4.0'; +// TODO(#268) address all TODO(server-5), TODO(server-6), and TODO(server-7) +const kMinSupportedZulipVersion = '7.0'; /// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release. /// /// For this value, see the API changelog: /// https://zulip.com/api/changelog -const kMinSupportedZulipFeatureLevel = 65; +const kMinSupportedZulipFeatureLevel = 185; /// The doc stating our oldest supported server version. // TODO: Instead, link to new Help Center doc once we have it: diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0479b0428f..4a5f9268e0 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -37,6 +37,13 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'saved_snippets': + switch (json['op'] as String) { + case 'add': return SavedSnippetsAddEvent.fromJson(json); + case 'update': return SavedSnippetsUpdateEvent.fromJson(json); + case 'remove': return SavedSnippetsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); @@ -55,6 +62,7 @@ sealed class Event { } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers case 'user_topic': return UserTopicEvent.fromJson(json); + case 'muted_users': return MutedUsersEvent.fromJson(json); case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); @@ -66,6 +74,7 @@ sealed class Event { } case 'submessage': return SubmessageEvent.fromJson(json); case 'typing': return TypingEvent.fromJson(json); + case 'presence': return PresenceEvent.fromJson(json); case 'reaction': return ReactionEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types @@ -336,6 +345,68 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `saved_snippets`: https://zulip.com/api/get-events#saved_snippets-add +sealed class SavedSnippetsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'saved_snippets'; + + String get op; + + SavedSnippetsEvent({required super.id}); +} + +/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsAddEvent extends SavedSnippetsEvent { + @override + String get op => 'add'; + + final SavedSnippet savedSnippet; + + SavedSnippetsAddEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsAddEvent.fromJson(Map json) => + _$SavedSnippetsAddEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsAddEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsUpdateEvent extends SavedSnippetsEvent { + @override + String get op => 'update'; + + final SavedSnippet savedSnippet; + + SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsUpdateEvent.fromJson(Map json) => + _$SavedSnippetsUpdateEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsUpdateEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsRemoveEvent extends SavedSnippetsEvent { + @override + String get op => 'remove'; + + final int savedSnippetId; + + SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId}); + + factory SavedSnippetsRemoveEvent.fromJson(Map json) => + _$SavedSnippetsRemoveEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for @@ -664,6 +735,24 @@ class UserTopicEvent extends Event { Map toJson() => _$UserTopicEventToJson(this); } +/// A Zulip event of type `muted_users`: https://zulip.com/api/get-events#muted_users +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUsersEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'muted_users'; + + final List mutedUsers; + + MutedUsersEvent({required super.id, required this.mutedUsers}); + + factory MutedUsersEvent.fromJson(Map json) => + _$MutedUsersEventFromJson(json); + + @override + Map toJson() => _$MutedUsersEventToJson(this); +} + /// A Zulip event of type `message`: https://zulip.com/api/get-events#message @JsonSerializable(fieldRename: FieldRename.snake) class MessageEvent extends Event { @@ -674,11 +763,6 @@ class MessageEvent extends Event { // In the server API, the `flags` field appears directly on the event rather // than on the message object. To avoid proliferating message types, we // normalize that away in deserialization. - // - // The other difference in the server API between message objects in these - // events and in the get-messages results is that `matchContent` and - // `matchTopic` are absent here. Already [Message.matchContent] and - // [Message.matchTopic] are optional, so no action is needed on that. @JsonKey(readValue: _readMessageValue, fromJson: Message.fromJson, includeToJson: false) final Message message; @@ -1107,6 +1191,69 @@ enum TypingOp { String toJson() => _$TypingOpEnumMap[this]!; } +/// A Zulip event of type `presence`. +/// +/// See: +/// https://zulip.com/api/get-events#presence +@JsonSerializable(fieldRename: FieldRename.snake) +class PresenceEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'presence'; + + final int userId; + // final String email; // deprecated; ignore + final int serverTimestamp; + final Map presence; + + PresenceEvent({ + required super.id, + required this.userId, + required this.serverTimestamp, + required this.presence, + }); + + factory PresenceEvent.fromJson(Map json) => + _$PresenceEventFromJson(json); + + @override + Map toJson() => _$PresenceEventToJson(this); +} + +/// A value in [PresenceEvent.presence]. +/// +/// The "per client" name follows the event's structure, +/// but that structure is already an API wart; see the doc's "Changes" note +/// on [client] and on the `client_name` key of the map that holds these values: +/// +/// https://zulip.com/api/get-events#presence +/// > Starting with Zulip 7.0 (feature level 178), this will always be "website" +/// > as the server no longer stores which client submitted presence updates. +/// +/// This will probably be deprecated in favor of a form like [PerUserPresence]. +/// See #1611 and discussion: +/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2200812 +// TODO(#1611) update comment about #1611 +@JsonSerializable(fieldRename: FieldRename.snake) +class PerClientPresence { + final String client; // always "website" (on 7.0+, so on all supported servers) + final PresenceStatus status; + final int timestamp; + final bool pushable; // always false (on 7.0+, so on all supported servers) + + PerClientPresence({ + required this.client, + required this.status, + required this.timestamp, + required this.pushable, + }); + + factory PerClientPresence.fromJson(Map json) => + _$PerClientPresenceFromJson(json); + + Map toJson() => _$PerClientPresenceToJson(this); +} + /// A Zulip event of type `reaction`, with op `add` or `remove`. /// /// See: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 35206d77b9..2203b2d9df 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -29,10 +29,9 @@ Map _$RealmEmojiUpdateEventToJson( AlertWordsEvent _$AlertWordsEventFromJson(Map json) => AlertWordsEvent( id: (json['id'] as num).toInt(), - alertWords: - (json['alert_words'] as List) - .map((e) => e as String) - .toList(), + alertWords: (json['alert_words'] as List) + .map((e) => e as String) + .toList(), ); Map _$AlertWordsEventToJson(AlertWordsEvent instance) => @@ -74,10 +73,9 @@ CustomProfileFieldsEvent _$CustomProfileFieldsEventFromJson( Map json, ) => CustomProfileFieldsEvent( id: (json['id'] as num).toInt(), - fields: - (json['fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), + fields: (json['fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), ); Map _$CustomProfileFieldsEventToJson( @@ -122,8 +120,8 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( Map json, ) => RealmUserUpdateEvent( id: (json['id'] as num).toInt(), - userId: - (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num).toInt(), + userId: (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num) + .toInt(), fullName: RealmUserUpdateEvent._readFromPerson(json, 'full_name') as String?, avatarUrl: RealmUserUpdateEvent._readFromPerson(json, 'avatar_url') as String?, @@ -151,11 +149,11 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( ), customProfileField: RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') == null - ? null - : RealmUserUpdateCustomProfileField.fromJson( - RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') - as Map, - ), + ? null + : RealmUserUpdateCustomProfileField.fromJson( + RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') + as Map, + ), newEmail: RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, isActive: RealmUserUpdateEvent._readFromPerson(json, 'is_active') as bool?, ); @@ -203,13 +201,61 @@ Json? _$JsonConverterToJson( Json? Function(Value value) toJson, ) => value == null ? null : toJson(value); +SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( + Map json, +) => SavedSnippetsAddEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsAddEventToJson( + SavedSnippetsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson( + Map json, +) => SavedSnippetsUpdateEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsUpdateEventToJson( + SavedSnippetsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson( + Map json, +) => SavedSnippetsRemoveEvent( + id: (json['id'] as num).toInt(), + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$SavedSnippetsRemoveEventToJson( + SavedSnippetsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet_id': instance.savedSnippetId, +}; + ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( id: (json['id'] as num).toInt(), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), ); Map _$ChannelCreateEventToJson(ChannelCreateEvent instance) => @@ -223,10 +269,9 @@ Map _$ChannelCreateEventToJson(ChannelCreateEvent instance) => ChannelDeleteEvent _$ChannelDeleteEventFromJson(Map json) => ChannelDeleteEvent( id: (json['id'] as num).toInt(), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), ); Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => @@ -282,10 +327,9 @@ SubscriptionAddEvent _$SubscriptionAddEventFromJson( Map json, ) => SubscriptionAddEvent( id: (json['id'] as num).toInt(), - subscriptions: - (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), ); Map _$SubscriptionAddEventToJson( @@ -354,14 +398,12 @@ SubscriptionPeerAddEvent _$SubscriptionPeerAddEventFromJson( Map json, ) => SubscriptionPeerAddEvent( id: (json['id'] as num).toInt(), - streamIds: - (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$SubscriptionPeerAddEventToJson( @@ -378,14 +420,12 @@ SubscriptionPeerRemoveEvent _$SubscriptionPeerRemoveEventFromJson( Map json, ) => SubscriptionPeerRemoveEvent( id: (json['id'] as num).toInt(), - streamIds: - (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$SubscriptionPeerRemoveEventToJson( @@ -428,6 +468,21 @@ const _$UserTopicVisibilityPolicyEnumMap = { UserTopicVisibilityPolicy.unknown: null, }; +MutedUsersEvent _$MutedUsersEventFromJson(Map json) => + MutedUsersEvent( + id: (json['id'] as num).toInt(), + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + ); + +Map _$MutedUsersEventToJson(MutedUsersEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'muted_users': instance.mutedUsers, + }; + MessageEvent _$MessageEventFromJson(Map json) => MessageEvent( id: (json['id'] as num).toInt(), message: Message.fromJson( @@ -449,14 +504,12 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => userId: (json['user_id'] as num?)?.toInt(), renderingOnly: json['rendering_only'] as bool?, messageId: (json['message_id'] as num).toInt(), - messageIds: - (json['message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - flags: - (json['flags'] as List) - .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) - .toList(), + messageIds: (json['message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + flags: (json['flags'] as List) + .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) + .toList(), editTimestamp: (json['edit_timestamp'] as num?)?.toInt(), moveData: UpdateMessageMoveData.tryParseFromJson( UpdateMessageEvent._readMoveData(json, 'move_data') @@ -500,18 +553,16 @@ const _$MessageFlagEnumMap = { DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => DeleteMessageEvent( id: (json['id'] as num).toInt(), - messageIds: - (json['message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messageIds: (json['message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), messageType: const MessageTypeConverter().fromJson( json['message_type'] as String, ), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$DeleteMessageEventToJson(DeleteMessageEvent instance) => @@ -533,10 +584,9 @@ UpdateMessageFlagsAddEvent _$UpdateMessageFlagsAddEventFromJson( json['flag'], unknownValue: MessageFlag.unknown, ), - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), all: json['all'] as bool, ); @@ -560,10 +610,9 @@ UpdateMessageFlagsRemoveEvent _$UpdateMessageFlagsRemoveEventFromJson( json['flag'], unknownValue: MessageFlag.unknown, ), - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), messageDetails: (json['message_details'] as Map?)?.map( (k, e) => MapEntry( int.parse(k), @@ -590,15 +639,13 @@ UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( ) => UpdateMessageFlagsMessageDetail( type: const MessageTypeConverter().fromJson(json['type'] as String), mentioned: json['mentioned'] as bool?, - userIds: - (json['user_ids'] as List?) - ?.map((e) => (e as num).toInt()) - .toList(), + userIds: (json['user_ids'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$UpdateMessageFlagsMessageDetailToJson( @@ -650,10 +697,9 @@ TypingEvent _$TypingEventFromJson(Map json) => TypingEvent( senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(), recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$TypingEventToJson(TypingEvent instance) => @@ -670,6 +716,47 @@ Map _$TypingEventToJson(TypingEvent instance) => const _$TypingOpEnumMap = {TypingOp.start: 'start', TypingOp.stop: 'stop'}; +PresenceEvent _$PresenceEventFromJson(Map json) => + PresenceEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + serverTimestamp: (json['server_timestamp'] as num).toInt(), + presence: (json['presence'] as Map).map( + (k, e) => + MapEntry(k, PerClientPresence.fromJson(e as Map)), + ), + ); + +Map _$PresenceEventToJson(PresenceEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'user_id': instance.userId, + 'server_timestamp': instance.serverTimestamp, + 'presence': instance.presence, + }; + +PerClientPresence _$PerClientPresenceFromJson(Map json) => + PerClientPresence( + client: json['client'] as String, + status: $enumDecode(_$PresenceStatusEnumMap, json['status']), + timestamp: (json['timestamp'] as num).toInt(), + pushable: json['pushable'] as bool, + ); + +Map _$PerClientPresenceToJson(PerClientPresence instance) => + { + 'client': instance.client, + 'status': instance.status, + 'timestamp': instance.timestamp, + 'pushable': instance.pushable, + }; + +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', +}; + ReactionEvent _$ReactionEventFromJson(Map json) => ReactionEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 054230a256..a9efdabdd5 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -34,6 +34,9 @@ class InitialSnapshot { /// * https://zulip.com/api/update-realm-user-settings-defaults#parameter-email_address_visibility final EmailAddressVisibility? emailAddressVisibility; // TODO(server-7): remove + final int serverPresencePingIntervalSeconds; + final int serverPresenceOfflineThresholdSeconds; + // TODO(server-8): Remove the default values. @JsonKey(defaultValue: 15000) final int serverTypingStartedExpiryPeriodMilliseconds; @@ -44,10 +47,19 @@ class InitialSnapshot { // final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers + final List mutedUsers; + + // In the modern format because we pass `slim_presence`. + // TODO(#1611) stop passing and mentioning the deprecated slim_presence; + // presence_last_update_id will be why we get the modern format. + final Map presences; + final Map realmEmoji; final List recentPrivateConversations; + final List? savedSnippets; // TODO(server-10) + final List subscriptions; final UnreadMessagesSnapshot unreadMsgs; @@ -82,6 +94,8 @@ class InitialSnapshot { final bool realmAllowMessageEditing; final int? realmMessageContentEditLimitSeconds; + final bool realmPresenceDisabled; + final Map realmDefaultExternalAccounts; final int maxFileUploadSizeMib; @@ -127,11 +141,16 @@ class InitialSnapshot { required this.alertWords, required this.customProfileFields, required this.emailAddressVisibility, + required this.serverPresencePingIntervalSeconds, + required this.serverPresenceOfflineThresholdSeconds, required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, + required this.mutedUsers, + required this.presences, required this.realmEmoji, required this.recentPrivateConversations, + required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, @@ -142,6 +161,7 @@ class InitialSnapshot { required this.realmWaitingPeriodThreshold, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, + required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 570d7c2bba..c33c457226 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -16,16 +16,20 @@ InitialSnapshot _$InitialSnapshotFromJson( zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), zulipVersion: json['zulip_version'] as String, zulipMergeBase: json['zulip_merge_base'] as String?, - alertWords: - (json['alert_words'] as List).map((e) => e as String).toList(), - customProfileFields: - (json['custom_profile_fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), + alertWords: (json['alert_words'] as List) + .map((e) => e as String) + .toList(), + customProfileFields: (json['custom_profile_fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), emailAddressVisibility: $enumDecodeNullable( _$EmailAddressVisibilityEnumMap, json['email_address_visibility'], ), + serverPresencePingIntervalSeconds: + (json['server_presence_ping_interval_seconds'] as num).toInt(), + serverPresenceOfflineThresholdSeconds: + (json['server_presence_offline_threshold_seconds'] as num).toInt(), serverTypingStartedExpiryPeriodMilliseconds: (json['server_typing_started_expiry_period_milliseconds'] as num?) ?.toInt() ?? @@ -38,6 +42,15 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['server_typing_started_wait_period_milliseconds'] as num?) ?.toInt() ?? 10000, + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + presences: (json['presences'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), realmEmoji: (json['realm_emoji'] as Map).map( (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), ), @@ -45,37 +58,35 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), - subscriptions: - (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), + savedSnippets: (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), unreadMsgs: UnreadMessagesSnapshot.fromJson( json['unread_msgs'] as Map, ), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), - userSettings: - json['user_settings'] == null - ? null - : UserSettings.fromJson( - json['user_settings'] as Map, - ), - userTopics: - (json['user_topics'] as List?) - ?.map((e) => UserTopicItem.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), + userSettings: json['user_settings'] == null + ? null + : UserSettings.fromJson(json['user_settings'] as Map), + userTopics: (json['user_topics'] as List?) + ?.map((e) => UserTopicItem.fromJson(e as Map)) + .toList(), realmWildcardMentionPolicy: $enumDecode( _$RealmWildcardMentionPolicyEnumMap, json['realm_wildcard_mention_policy'], ), realmMandatoryTopics: json['realm_mandatory_topics'] as bool, - realmWaitingPeriodThreshold: - (json['realm_waiting_period_threshold'] as num).toInt(), + realmWaitingPeriodThreshold: (json['realm_waiting_period_threshold'] as num) + .toInt(), realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, realmMessageContentEditLimitSeconds: (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), + realmPresenceDisabled: json['realm_presence_disabled'] as bool, realmDefaultExternalAccounts: (json['realm_default_external_accounts'] as Map).map( (k, e) => MapEntry( @@ -84,10 +95,9 @@ InitialSnapshot _$InitialSnapshotFromJson( ), ), maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), - serverEmojiDataUrl: - json['server_emoji_data_url'] == null - ? null - : Uri.parse(json['server_emoji_data_url'] as String), + serverEmojiDataUrl: json['server_emoji_data_url'] == null + ? null + : Uri.parse(json['server_emoji_data_url'] as String), realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, realmUsers: (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') @@ -120,14 +130,21 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'custom_profile_fields': instance.customProfileFields, 'email_address_visibility': _$EmailAddressVisibilityEnumMap[instance.emailAddressVisibility], + 'server_presence_ping_interval_seconds': + instance.serverPresencePingIntervalSeconds, + 'server_presence_offline_threshold_seconds': + instance.serverPresenceOfflineThresholdSeconds, 'server_typing_started_expiry_period_milliseconds': instance.serverTypingStartedExpiryPeriodMilliseconds, 'server_typing_stopped_wait_period_milliseconds': instance.serverTypingStoppedWaitPeriodMilliseconds, 'server_typing_started_wait_period_milliseconds': instance.serverTypingStartedWaitPeriodMilliseconds, + 'muted_users': instance.mutedUsers, + 'presences': instance.presences.map((k, e) => MapEntry(k.toString(), e)), 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, 'subscriptions': instance.subscriptions, 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, @@ -139,6 +156,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'realm_allow_message_editing': instance.realmAllowMessageEditing, 'realm_message_content_edit_limit_seconds': instance.realmMessageContentEditLimitSeconds, + 'realm_presence_disabled': instance.realmPresenceDisabled, 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), @@ -187,10 +205,9 @@ RecentDmConversation _$RecentDmConversationFromJson( Map json, ) => RecentDmConversation( maxMessageId: (json['max_message_id'] as num).toInt(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$RecentDmConversationToJson( @@ -258,22 +275,18 @@ UnreadMessagesSnapshot _$UnreadMessagesSnapshotFromJson( Map json, ) => UnreadMessagesSnapshot( count: (json['count'] as num).toInt(), - dms: - (json['pms'] as List) - .map((e) => UnreadDmSnapshot.fromJson(e as Map)) - .toList(), - channels: - (json['streams'] as List) - .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) - .toList(), - huddles: - (json['huddles'] as List) - .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) - .toList(), - mentions: - (json['mentions'] as List) - .map((e) => (e as num).toInt()) - .toList(), + dms: (json['pms'] as List) + .map((e) => UnreadDmSnapshot.fromJson(e as Map)) + .toList(), + channels: (json['streams'] as List) + .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) + .toList(), + huddles: (json['huddles'] as List) + .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) + .toList(), + mentions: (json['mentions'] as List) + .map((e) => (e as num).toInt()) + .toList(), oldUnreadsMissing: json['old_unreads_missing'] as bool, ); @@ -293,10 +306,9 @@ UnreadDmSnapshot _$UnreadDmSnapshotFromJson(Map json) => otherUserId: (UnreadDmSnapshot._readOtherUserId(json, 'other_user_id') as num) .toInt(), - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) => @@ -310,10 +322,9 @@ UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson( ) => UnreadChannelSnapshot( topic: TopicName.fromJson(json['topic'] as String), streamId: (json['stream_id'] as num).toInt(), - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadChannelSnapshotToJson( @@ -328,10 +339,9 @@ UnreadHuddleSnapshot _$UnreadHuddleSnapshotFromJson( Map json, ) => UnreadHuddleSnapshot( userIdsString: json['user_ids_string'] as String, - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadHuddleSnapshotToJson( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 90769e815b..ae4fb7b5a3 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,6 +110,25 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } +/// An item in the [InitialSnapshot.mutedUsers] or [MutedUsersEvent]. +/// +/// For docs, search for "muted_users:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUserItem { + final int id; + + // Mobile doesn't use the timestamp; ignore. + // final int timestamp; + + const MutedUserItem({required this.id}); + + factory MutedUserItem.fromJson(Map json) => + _$MutedUserItemFromJson(json); + + Map toJson() => _$MutedUserItemToJson(this); +} + /// An item in [InitialSnapshot.realmEmoji] or [RealmEmojiUpdateEvent]. /// /// For docs, search for "realm_emoji:" @@ -311,6 +330,59 @@ enum UserRole{ } } +/// A value in [InitialSnapshot.presences]. +/// +/// For docs, search for "presences:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class PerUserPresence { + final int activeTimestamp; + final int idleTimestamp; + + PerUserPresence({ + required this.activeTimestamp, + required this.idleTimestamp, + }); + + factory PerUserPresence.fromJson(Map json) => + _$PerUserPresenceFromJson(json); + + Map toJson() => _$PerUserPresenceToJson(this); +} + +/// As in [PerClientPresence.status] and [updatePresence]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum PresenceStatus { + active, + idle; + + String toJson() => _$PresenceStatusEnumMap[this]!; +} + +/// An item in `saved_snippets` from the initial snapshot. +/// +/// For docs, search for "saved_snippets:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippet { + SavedSnippet({ + required this.id, + required this.title, + required this.content, + required this.dateCreated, + }); + + final int id; + final String title; + final String content; + final int dateCreated; + + factory SavedSnippet.fromJson(Map json) => + _$SavedSnippetFromJson(json); + + Map toJson() => _$SavedSnippetToJson(this); +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. @@ -550,6 +622,15 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) { } } +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + /// The name of a Zulip topic. // TODO(dart): Can we forbid calling Object members on this extension type? // (The lack of "implements Object" ought to do that, but doesn't.) @@ -581,11 +662,7 @@ extension type const TopicName(String _value) { /// The string this topic is displayed as to the user in our UI. /// /// At the moment this always equals [apiName]. - /// In the future this will become null for the "general chat" topic (#1250), - /// so that UI code can identify when it needs to represent the topic - /// specially in the way prescribed for "general chat". - // TODO(#1250) carry out that plan - String get displayName => _value; + String? get displayName => _value.isEmpty ? null : _value; /// The key to use for "same topic as" comparisons. String canonicalize() => apiName.toLowerCase(); @@ -604,6 +681,53 @@ extension type const TopicName(String _value) { /// using [canonicalize]. bool isSameAs(TopicName other) => canonicalize() == other.canonicalize(); + /// Process this topic to match how it would appear on a message object from + /// the server. + /// + /// This returns the [TopicName] the server would be predicted to include + /// in a message object resulting from sending to this [TopicName] + /// in a [sendMessage] request. + /// + /// This [TopicName] is required to have no leading or trailing whitespace. + /// + /// For a client that supports empty topics, when FL>=334, the server converts + /// `store.realmEmptyTopicDisplayName` to an empty string; when FL>=370, + /// the server converts "(no topic)" to an empty string as well. + /// + /// See API docs: + /// https://zulip.com/api/send-message#parameter-topic + TopicName processLikeServer({ + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + assert(_value.trim() == _value); + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 334) { + // From the API docs: + // > Before Zulip 10.0 (feature level 334), empty string was not a valid + // > topic name for channel messages. + assert(_value.isNotEmpty); + return this; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && _value == kNoTopicTopic) { + // From the API docs: + // > Before Zulip 10.0 (feature level 370), "(no topic)" was not + // > interpreted as an empty string. + return TopicName(kNoTopicTopic); + } + + if (_value == kNoTopicTopic || _value == realmEmptyTopicDisplayName) { + // From the API docs: + // > When "(no topic)" or the value of realm_empty_topic_display_name + // > found in the POST /register response is used for [topic], + // > it is interpreted as an empty string. + return TopicName(''); + } + return TopicName(_value); + } + TopicName.fromJson(this._value); String toJson() => apiName; @@ -614,7 +738,10 @@ extension type const TopicName(String _value) { /// Different from [MessageDestination], this information comes from /// [getMessages] or [getEvents], identifying the conversation that contains a /// message. -sealed class Conversation {} +sealed class Conversation { + /// Whether [this] and [other] refer to the same Zulip conversation. + bool isSameAs(Conversation other); +} /// The conversation a stream message is in. @JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) @@ -640,6 +767,13 @@ class StreamConversation extends Conversation { factory StreamConversation.fromJson(Map json) => _$StreamConversationFromJson(json); + + @override + bool isSameAs(Conversation other) { + return other is StreamConversation + && streamId == other.streamId + && topic.isSameAs(other.topic); + } } /// The conversation a DM message is in. @@ -653,6 +787,21 @@ class DmConversation extends Conversation { DmConversation({required this.allRecipientIds}) : assert(isSortedWithoutDuplicates(allRecipientIds.toList())); + + bool _equalIdSequences(Iterable xs, Iterable ys) { + if (xs.length != ys.length) return false; + final xs_ = xs.iterator; final ys_ = ys.iterator; + while (xs_.moveNext() && ys_.moveNext()) { + if (xs_.current != ys_.current) return false; + } + return true; + } + + @override + bool isSameAs(Conversation other) { + if (other is! DmConversation) return false; + return _equalIdSequences(allRecipientIds, other.allRecipientIds); + } } /// A message or message-like object, for showing in a message list. @@ -716,9 +865,8 @@ sealed class Message extends MessageBase { // final string type; // handled by runtime type of object @JsonKey(fromJson: _flagsFromJson) List flags; // Unrecognized flags won't roundtrip through {to,from}Json. - final String? matchContent; - @JsonKey(name: 'match_subject') - final String? matchTopic; + // TODO(#1663) Add matchContent and matchTopic back again; + // revert the commit that removed these and related test/comment changes. static MessageEditState _messageEditStateFromJson(Object? json) { // This is a no-op so that [MessageEditState._readFromMessage] @@ -763,8 +911,6 @@ sealed class Message extends MessageBase { required this.senderRealmStr, required super.timestamp, required this.flags, - required this.matchContent, - required this.matchTopic, }); // TODO(dart): This has to be a static method, because factories/constructors @@ -848,8 +994,6 @@ class StreamMessage extends Message { required super.senderRealmStr, required super.timestamp, required super.flags, - required super.matchContent, - required super.matchTopic, required this.conversation, }); @@ -910,8 +1054,6 @@ class DmMessage extends Message { required super.senderRealmStr, required super.timestamp, required super.flags, - required super.matchContent, - required super.matchTopic, required this.conversation, }); diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cddf78beb0..453d204737 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -68,6 +68,12 @@ Map _$CustomProfileFieldExternalAccountDataToJson( 'url_pattern': instance.urlPattern, }; +MutedUserItem _$MutedUserItemFromJson(Map json) => + MutedUserItem(id: (json['id'] as num).toInt()); + +Map _$MutedUserItemToJson(MutedUserItem instance) => + {'id': instance.id}; + RealmEmojiItem _$RealmEmojiItemFromJson(Map json) => RealmEmojiItem( emojiCode: json['id'] as String, @@ -107,14 +113,14 @@ User _$UserFromJson(Map json) => User( timezone: json['timezone'] as String, avatarUrl: json['avatar_url'] as String?, avatarVersion: (json['avatar_version'] as num).toInt(), - profileData: (User._readProfileData(json, 'profile_data') - as Map?) - ?.map( - (k, e) => MapEntry( - int.parse(k), - ProfileFieldUserData.fromJson(e as Map), - ), - ), + profileData: + (User._readProfileData(json, 'profile_data') as Map?) + ?.map( + (k, e) => MapEntry( + int.parse(k), + ProfileFieldUserData.fromJson(e as Map), + ), + ), isSystemBot: User._readIsSystemBot(json, 'is_system_bot') as bool, ); @@ -162,6 +168,33 @@ Map _$ProfileFieldUserDataToJson( 'rendered_value': instance.renderedValue, }; +PerUserPresence _$PerUserPresenceFromJson(Map json) => + PerUserPresence( + activeTimestamp: (json['active_timestamp'] as num).toInt(), + idleTimestamp: (json['idle_timestamp'] as num).toInt(), + ); + +Map _$PerUserPresenceToJson(PerUserPresence instance) => + { + 'active_timestamp': instance.activeTimestamp, + 'idle_timestamp': instance.idleTimestamp, + }; + +SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + dateCreated: (json['date_created'] as num).toInt(), +); + +Map _$SavedSnippetToJson(SavedSnippet instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'date_created': instance.dateCreated, + }; + ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, @@ -286,8 +319,6 @@ StreamMessage _$StreamMessageFromJson(Map json) => senderRealmStr: json['sender_realm_str'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), - matchContent: json['match_content'] as String?, - matchTopic: json['match_subject'] as String?, conversation: StreamConversation.fromJson( StreamMessage._readConversation(json, 'conversation') as Map, @@ -312,8 +343,6 @@ Map _$StreamMessageToJson(StreamMessage instance) => 'sender_realm_str': instance.senderRealmStr, 'submessages': Poll.toJson(instance.poll), 'flags': instance.flags, - 'match_content': instance.matchContent, - 'match_subject': instance.matchTopic, 'type': instance.type, 'stream_id': instance.streamId, 'subject': instance.topic, @@ -344,8 +373,6 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( senderRealmStr: json['sender_realm_str'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), - matchContent: json['match_content'] as String?, - matchTopic: json['match_subject'] as String?, conversation: DmMessage._conversationFromJson( json['display_recipient'] as List, ), @@ -368,8 +395,6 @@ Map _$DmMessageToJson(DmMessage instance) => { 'sender_realm_str': instance.senderRealmStr, 'submessages': Poll.toJson(instance.poll), 'flags': instance.flags, - 'match_content': instance.matchContent, - 'match_subject': instance.matchTopic, 'type': instance.type, 'display_recipient': DmMessage._allRecipientIdsToJson( instance.allRecipientIds, @@ -389,6 +414,11 @@ const _$EmojisetEnumMap = { Emojiset.text: 'text', }; +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', +}; + const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', ChannelPropertyName.description: 'description', diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 641e78de49..8faabba6f0 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -173,18 +173,16 @@ class ApiNarrowPmWith extends ApiNarrowDm { ApiNarrowPmWith._(super.operand, {super.negated}); } -/// An [ApiNarrowElement] with the 'with' operator. -/// -/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. -class ApiNarrowWith extends ApiNarrowElement { - @override String get operator => 'with'; +/// An [ApiNarrowElement] with the 'search' operator. +class ApiNarrowSearch extends ApiNarrowElement { + @override String get operator => 'search'; - @override final int operand; + @override final String operand; - ApiNarrowWith(this.operand, {super.negated}); + ApiNarrowSearch(this.operand, {super.negated}); - factory ApiNarrowWith.fromJson(Map json) => ApiNarrowWith( - json['operand'] as int, + factory ApiNarrowSearch.fromJson(Map json) => ApiNarrowSearch( + json['operand'] as String, negated: json['negated'] as bool? ?? false, ); } @@ -229,6 +227,22 @@ enum IsOperand { String toJson() => toString(); } +/// An [ApiNarrowElement] with the 'with' operator. +/// +/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. +class ApiNarrowWith extends ApiNarrowElement { + @override String get operator => 'with'; + + @override final int operand; + + ApiNarrowWith(this.operand, {super.negated}); + + factory ApiNarrowWith.fromJson(Map json) => ApiNarrowWith( + json['operand'] as int, + negated: json['negated'] as bool? ?? false, + ); +} + class ApiNarrowMessageId extends ApiNarrowElement { @override String get operator => 'id'; diff --git a/lib/api/model/submessage.dart b/lib/api/model/submessage.dart index f338265b46..81181f061f 100644 --- a/lib/api/model/submessage.dart +++ b/lib/api/model/submessage.dart @@ -64,6 +64,7 @@ class Submessage { widgetData: widgetData, pollEventSubmessages: submessages.skip(1), messageSenderId: messageSenderId, + debugSubmessages: kDebugMode ? submessages : null, ); case UnsupportedWidgetData(): assert(debugLog('Unsupported widgetData: ${widgetData.json}')); @@ -368,11 +369,13 @@ class Poll extends ChangeNotifier { required PollWidgetData widgetData, required Iterable pollEventSubmessages, required int messageSenderId, + required List? debugSubmessages, }) { final poll = Poll._( messageSenderId: messageSenderId, question: widgetData.extraData.question, options: widgetData.extraData.options, + debugSubmessages: debugSubmessages, ); for (final submessage in pollEventSubmessages) { @@ -386,17 +389,23 @@ class Poll extends ChangeNotifier { required this.messageSenderId, required this.question, required List options, + required List? debugSubmessages, }) { for (int index = 0; index < options.length; index += 1) { // Initial poll options use a placeholder senderId. // See [PollEventSubmessage.optionKey] for details. _addOption(senderId: null, idx: index, option: options[index]); } + if (kDebugMode) { + _debugSubmessages = debugSubmessages; + } } final int messageSenderId; String question; + List? _debugSubmessages; + /// The limit of options any single user can add to a poll. /// /// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71 @@ -417,6 +426,14 @@ class Poll extends ChangeNotifier { } _applyEvent(event.senderId, pollEventSubmessage); notifyListeners(); + + if (kDebugMode) { + assert(_debugSubmessages != null); + _debugSubmessages!.add(Submessage( + senderId: event.senderId, + msgType: event.msgType, + content: event.content)); + } } void _applyEvent(int senderId, PollEventSubmessage event) { @@ -472,9 +489,18 @@ class Poll extends ChangeNotifier { } static List toJson(Poll? poll) { - // Rather than maintaining a up-to-date submessages list, return as if it is - // empty, because we are not sending the submessages to the server anyway. - return []; + List? result; + + if (kDebugMode) { + // Useful for setting up a message list with a poll message, which goes + // through this codepath (when preparing a fetch response). + result = poll?._debugSubmessages; + } + + // In prod, rather than maintaining a up-to-date submessages list, + // return as if it is empty, because we are not sending the submessages + // to the server anyway. + return result ?? []; } } diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index bfa46f5ab8..8ae2076038 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -7,8 +7,12 @@ part 'channels.g.dart'; /// https://zulip.com/api/get-stream-topics Future getStreamTopics(ApiConnection connection, { required int streamId, + required bool allowEmptyTopicName, }) { - return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {}); + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); + return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', { + 'allow_empty_topic_name': allowEmptyTopicName, + }); } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index f12b4db05f..c43f0f50f0 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -11,10 +11,9 @@ part of 'channels.dart'; GetStreamTopicsResult _$GetStreamTopicsResultFromJson( Map json, ) => GetStreamTopicsResult( - topics: - (json['topics'] as List) - .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) - .toList(), + topics: (json['topics'] as List) + .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) + .toList(), ); Map _$GetStreamTopicsResultToJson( diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index bbd7be5a0a..bd14521c74 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -18,6 +18,7 @@ Future registerQueue(ApiConnection connection) { 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': true, 'user_settings_object': true, + 'empty_topic_name': true, }, }); } diff --git a/lib/api/route/events.g.dart b/lib/api/route/events.g.dart index 5866787fc6..3c77877ae8 100644 --- a/lib/api/route/events.g.dart +++ b/lib/api/route/events.g.dart @@ -10,10 +10,9 @@ part of 'events.dart'; GetEventsResult _$GetEventsResultFromJson(Map json) => GetEventsResult( - events: - (json['events'] as List) - .map((e) => Event.fromJson(e as Map)) - .toList(), + events: (json['events'] as List) + .map((e) => Event.fromJson(e as Map)) + .toList(), queueId: json['queue_id'] as String?, ); diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 6a42158b75..05364951cd 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -16,6 +16,7 @@ part 'messages.g.dart'; Future getMessageCompat(ApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) async { final useLegacyApi = connection.zulipFeatureLevel! < 120; if (useLegacyApi) { @@ -25,6 +26,7 @@ Future getMessageCompat(ApiConnection connection, { numBefore: 0, numAfter: 0, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, // Hard-code this param to `true`, as the new single-message API // effectively does: @@ -37,6 +39,7 @@ Future getMessageCompat(ApiConnection connection, { final response = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); return response.message; } on ZulipApiException catch (e) { @@ -57,10 +60,13 @@ Future getMessageCompat(ApiConnection connection, { Future getMessage(ApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) { + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); assert(connection.zulipFeatureLevel! >= 120); return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', { if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } @@ -89,8 +95,10 @@ Future getMessages(ApiConnection connection, { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, // bool? useFirstUnreadAnchor // omitted because deprecated }) { + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'anchor': RawParameter(anchor.toJson()), @@ -99,6 +107,7 @@ Future getMessages(ApiConnection connection, { 'num_after': numAfter, if (clientGravatar != null) 'client_gravatar': clientGravatar, if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } @@ -169,15 +178,6 @@ const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; -/// The topic servers understand to mean "there is no topic". -/// -/// This should match -/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 -/// or similar logic at the latest `main`. -// This is hardcoded in the server, and therefore untranslated; that's -// zulip/zulip#3639. -const String kNoTopicTopic = '(no topic)'; - /// https://zulip.com/api/send-message Future sendMessage( ApiConnection connection, { @@ -275,6 +275,7 @@ Future updateMessage( bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, }) { return connection.patch('updateMessage', UpdateMessageResult.fromJson, 'messages/$messageId', { @@ -283,6 +284,7 @@ Future updateMessage( if (sendNotificationToOldThread != null) 'send_notification_to_old_thread': sendNotificationToOldThread, if (sendNotificationToNewThread != null) 'send_notification_to_new_thread': sendNotificationToNewThread, if (content != null) 'content': RawParameter(content), + if (prevContentSha256 != null) 'prev_content_sha256': RawParameter(prevContentSha256), if (streamId != null) 'stream_id': streamId, }); } @@ -390,9 +392,6 @@ class UpdateMessageFlagsResult { } /// https://zulip.com/api/update-message-flags-for-narrow -/// -/// This binding only supports feature levels 155+. -// TODO(server-6) remove FL 155+ mention in doc, and the related `assert` Future updateMessageFlagsForNarrow(ApiConnection connection, { required Anchor anchor, bool? includeAnchor, @@ -402,7 +401,6 @@ Future updateMessageFlagsForNarrow(ApiConnect required UpdateMessageFlagsOp op, required MessageFlag flag, }) { - assert(connection.zulipFeatureLevel! >= 155); return connection.post('updateMessageFlagsForNarrow', UpdateMessageFlagsForNarrowResult.fromJson, 'messages/flags/narrow', { 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, @@ -437,63 +435,3 @@ class UpdateMessageFlagsForNarrowResult { Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); } - -/// https://zulip.com/api/mark-all-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -// -// For FL < 153 this call was atomic on the server and would -// not mark any messages as read if it timed out. -// From FL 153 and onward the server started processing -// in batches so progress could still be made in the event -// of a timeout interruption. Thus, in FL 153 this call -// started returning `result: partially_completed` and -// `code: REQUEST_TIMEOUT` for timeouts. -// -// In FL 211 the `partially_completed` variant of -// `result` was removed, the string `code` field also -// removed, and a boolean `complete` field introduced. -// -// For full support of this endpoint we would need three -// variants of the return structure based on feature -// level (`{}`, `{code: string}`, and `{complete: bool}`) -// as well as handling of `partially_completed` variant -// of `result` in `lib/api/core.dart`. For simplicity we -// ignore these return values. -// -// We don't use this method for FL 155+ (it is replaced -// by `updateMessageFlagsForNarrow`) so there are only -// two versions (FL 153 and FL 154) affected. -Future markAllAsRead(ApiConnection connection) { - return connection.post('markAllAsRead', (_) {}, 'mark_all_as_read', {}); -} - -/// https://zulip.com/api/mark-stream-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markStreamAsRead(ApiConnection connection, { - required int streamId, -}) { - return connection.post('markStreamAsRead', (_) {}, 'mark_stream_as_read', { - 'stream_id': streamId, - }); -} - -/// https://zulip.com/api/mark-topic-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markTopicAsRead(ApiConnection connection, { - required int streamId, - required TopicName topicName, -}) { - return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', { - 'stream_id': streamId, - 'topic_name': RawParameter(topicName.apiName), - }); -} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 21729f04da..0df3e678e6 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -58,10 +58,9 @@ Map _$UploadFileResultToJson(UploadFileResult instance) => UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson( Map json, ) => UpdateMessageFlagsResult( - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UpdateMessageFlagsResultToJson( diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart new file mode 100644 index 0000000000..047a35051e --- /dev/null +++ b/lib/api/route/saved_snippets.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'saved_snippets.g.dart'; + +/// https://zulip.com/api/create-saved-snippet +Future createSavedSnippet(ApiConnection connection, { + required String title, + required String content, +}) { + assert(connection.zulipFeatureLevel! >= 297); // TODO(server-10) + return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', { + 'title': RawParameter(title), + 'content': RawParameter(content), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class CreateSavedSnippetResult { + final int savedSnippetId; + + CreateSavedSnippetResult({ + required this.savedSnippetId, + }); + + factory CreateSavedSnippetResult.fromJson(Map json) => + _$CreateSavedSnippetResultFromJson(json); + + Map toJson() => _$CreateSavedSnippetResultToJson(this); +} diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart new file mode 100644 index 0000000000..aeb3c2a6c5 --- /dev/null +++ b/lib/api/route/saved_snippets.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'saved_snippets.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson( + Map json, +) => CreateSavedSnippetResult( + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$CreateSavedSnippetResultToJson( + CreateSavedSnippetResult instance, +) => {'saved_snippet_id': instance.savedSnippetId}; diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index 012f14e6b9..d07c471e2f 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../core.dart'; +import '../model/model.dart'; part 'users.g.dart'; @@ -32,3 +33,49 @@ class GetOwnUserResult { Map toJson() => _$GetOwnUserResultToJson(this); } + +/// https://zulip.com/api/update-presence +/// +/// Passes true for `slim_presence` to avoid getting an ancient data format +/// in the response. +// TODO(#1611) Passing `slim_presence` is the old, deprecated way to avoid +// getting an ancient data format. Pass `last_update_id` to new servers to get +// that effect (make lastUpdateId required?) and update the dartdoc. +// (Passing `slim_presence`, for now, shouldn't break things, but we'd like to +// stop; see discussion: +// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2201035 ) +Future updatePresence(ApiConnection connection, { + int? lastUpdateId, + int? historyLimitDays, + bool? newUserInput, + bool? pingOnly, + required PresenceStatus status, +}) { + return connection.post('updatePresence', UpdatePresenceResult.fromJson, 'users/me/presence', { + if (lastUpdateId != null) 'last_update_id': lastUpdateId, + if (historyLimitDays != null) 'history_limit_days': historyLimitDays, + if (newUserInput != null) 'new_user_input': newUserInput, + if (pingOnly != null) 'ping_only': pingOnly, + 'status': RawParameter(status.toJson()), + 'slim_presence': true, + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdatePresenceResult { + final int? presenceLastUpdateId; // TODO(server-9.0) new in FL 263 + final double? serverTimestamp; // 1656958539.6287155 in the example response + final Map? presences; + // final bool zephyrMirrorActive; // deprecated, ignore + + UpdatePresenceResult({ + required this.presenceLastUpdateId, + required this.serverTimestamp, + required this.presences, + }); + + factory UpdatePresenceResult.fromJson(Map json) => + _$UpdatePresenceResultFromJson(json); + + Map toJson() => _$UpdatePresenceResultToJson(this); +} diff --git a/lib/api/route/users.g.dart b/lib/api/route/users.g.dart index e03ccfc041..dab0e32189 100644 --- a/lib/api/route/users.g.dart +++ b/lib/api/route/users.g.dart @@ -13,3 +13,24 @@ GetOwnUserResult _$GetOwnUserResultFromJson(Map json) => Map _$GetOwnUserResultToJson(GetOwnUserResult instance) => {'user_id': instance.userId}; + +UpdatePresenceResult _$UpdatePresenceResultFromJson( + Map json, +) => UpdatePresenceResult( + presenceLastUpdateId: (json['presence_last_update_id'] as num?)?.toInt(), + serverTimestamp: (json['server_timestamp'] as num?)?.toDouble(), + presences: (json['presences'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), +); + +Map _$UpdatePresenceResultToJson( + UpdatePresenceResult instance, +) => { + 'presence_last_update_id': instance.presenceLastUpdateId, + 'server_timestamp': instance.serverTimestamp, + 'presences': instance.presences?.map((k, e) => MapEntry(k.toString(), e)), +}; diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e326703e4b..bdd5d60bdb 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -6,13 +6,17 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; import 'zulip_localizations_ar.dart'; +import 'zulip_localizations_de.dart'; import 'zulip_localizations_en.dart'; +import 'zulip_localizations_it.dart'; import 'zulip_localizations_ja.dart'; import 'zulip_localizations_nb.dart'; import 'zulip_localizations_pl.dart'; import 'zulip_localizations_ru.dart'; import 'zulip_localizations_sk.dart'; +import 'zulip_localizations_sl.dart'; import 'zulip_localizations_uk.dart'; +import 'zulip_localizations_zh.dart'; // ignore_for_file: type=lint @@ -102,12 +106,27 @@ abstract class ZulipLocalizations { static const List supportedLocales = [ Locale('en'), Locale('ar'), + Locale('de'), + Locale('en', 'GB'), + Locale('it'), Locale('ja'), Locale('nb'), Locale('pl'), Locale('ru'), Locale('sk'), + Locale('sl'), 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. @@ -134,6 +153,30 @@ abstract class ZulipLocalizations { /// **'Tap to view'** String get aboutPageTapToView; + /// Title for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Welcome to the new Zulip app!'** + String get upgradeWelcomeDialogTitle; + + /// Message text for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'You’ll find a familiar experience in a faster, sleeker package.'** + String get upgradeWelcomeDialogMessage; + + /// Text of link in dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Check out the announcement blog post!'** + String get upgradeWelcomeDialogLinkText; + + /// Label for button dismissing dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Let\'s go'** + String get upgradeWelcomeDialogDismiss; + /// Title for the page to choose between Zulip accounts. /// /// In en, this message translates to: @@ -236,6 +279,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: @@ -302,17 +351,23 @@ 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: /// **'Share'** String get actionSheetOptionShare; - /// Label for Quote and reply button on action sheet. + /// Label for the 'Quote message' button in the message action sheet. /// /// In en, this message translates to: - /// **'Quote and reply'** - String get actionSheetOptionQuoteAndReply; + /// **'Quote message'** + String get actionSheetOptionQuoteMessage; /// Label for star button on action sheet. /// @@ -326,6 +381,12 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Label for the 'Edit message' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get actionSheetOptionEditMessage; + /// Option to mark a specific topic as read in the action sheet. /// /// In en, this message translates to: @@ -359,7 +420,7 @@ abstract class ZulipLocalizations { /// Error message when the source of a message could not be fetched. /// /// In en, this message translates to: - /// **'Could not fetch message source'** + /// **'Could not fetch message source.'** String get errorCouldNotFetchMessageSource; /// Error message when copying the text of a message to the user's system clipboard failed. @@ -414,6 +475,12 @@ abstract class ZulipLocalizations { /// **'Message not sent'** String get errorMessageNotSent; + /// Error message for compose box when a message edit could not be saved. + /// + /// In en, this message translates to: + /// **'Message not saved'** + String get errorMessageEditNotSaved; + /// Error message when the app could not connect to the server. /// /// In en, this message translates to: @@ -526,6 +593,12 @@ abstract class ZulipLocalizations { /// **'Failed to unstar message'** String get errorUnstarMessageFailedTitle; + /// Error title when an exception prevented us from opening the compose box for editing a message. + /// + /// In en, this message translates to: + /// **'Could not edit message'** + String get errorCouldNotEditMessageTitle; + /// Success message after copy link action completed. /// /// In en, this message translates to: @@ -556,6 +629,72 @@ abstract class ZulipLocalizations { /// **'You do not have permission to post in this channel.'** String get errorBannerCannotPostInChannelLabel; + /// Label text for the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get composeBoxBannerLabelEditMessage; + + /// Label text for the 'Cancel' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get composeBoxBannerButtonCancel; + + /// Label text for the 'Save' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Save'** + String get composeBoxBannerButtonSave; + + /// Error title when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'Cannot edit message'** + String get editAlreadyInProgressTitle; + + /// Error message when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'An edit is already in progress. Please wait for it to complete.'** + String get editAlreadyInProgressMessage; + + /// 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.) + /// + /// In en, this message translates to: + /// **'SAVING EDIT…'** + String get savingMessageEditLabel; + + /// 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.) + /// + /// In en, this message translates to: + /// **'EDIT NOT SAVED'** + String get savingMessageEditFailedLabel; + + /// Title for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'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, 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 discardDraftForEditConfirmationDialogMessage; + + /// Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you restore an unsent message, the content that was previously in the compose box is discarded.'** + String get discardDraftForOutboxConfirmationDialogMessage; + + /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardDraftConfirmationDialogConfirmButton; + /// Tooltip for compose box icon to attach a file to the message. /// /// In en, this message translates to: @@ -580,6 +719,42 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; + /// Label for the compose button in the new DM sheet that starts composing a message to the selected users. + /// + /// In en, this message translates to: + /// **'Compose'** + String get newDmSheetComposeButtonLabel; + + /// 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: @@ -604,6 +779,12 @@ abstract class ZulipLocalizations { /// **'Message {destination}'** String composeBoxChannelContentHint(String destination); + /// Hint text for content input when the compose box is preparing to edit a message. + /// + /// In en, this message translates to: + /// **'Preparing…'** + String get preparingEditMessageContentInput; + /// Tooltip for send button in compose box. /// /// In en, this message translates to: @@ -622,6 +803,12 @@ abstract class ZulipLocalizations { /// **'Topic'** String get composeBoxTopicHintText; + /// Hint text for topic input widget in compose box when topics are optional. + /// + /// In en, this message translates to: + /// **'Enter a topic (skip for “{defaultTopicName}”)'** + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName); + /// Placeholder in compose box showing the specified file is currently uploading. /// /// In en, this message translates to: @@ -658,6 +845,18 @@ abstract class ZulipLocalizations { /// **'DMs with {others}'** String dmsWithOthersPageTitle(String others); + /// Placeholder for some message-list pages when there are no messages. + /// + /// In en, this message translates to: + /// **'There are no messages here.'** + String get emptyMessageList; + + /// Placeholder for the 'Search' page when there are no messages. + /// + /// In en, this message translates to: + /// **'No search results.'** + String get emptyMessageListSearch; + /// Message list recipient header for a DM group that only includes yourself. /// /// In en, this message translates to: @@ -857,7 +1056,7 @@ abstract class ZulipLocalizations { /// Error message when an API call returned an invalid response. /// /// In en, this message translates to: - /// **'The server sent an invalid response'** + /// **'The server sent an invalid response.'** String get errorInvalidResponse; /// Error message when a network request fails. @@ -887,7 +1086,7 @@ abstract class ZulipLocalizations { /// Error message when a video fails to play. /// /// In en, this message translates to: - /// **'Unable to play the video'** + /// **'Unable to play the video.'** String get errorVideoPlayerFailed; /// Error message when URL is empty @@ -1010,12 +1209,36 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// Page title for the 'Search' message view. + /// + /// In en, this message translates to: + /// **'Search'** + String get searchMessagesPageTitle; + + /// Hint text for the message search text field. + /// + /// In en, this message translates to: + /// **'Search'** + String get searchMessagesHintText; + + /// Tooltip for the 'x' button in the search text field. + /// + /// In en, this message translates to: + /// **'Clear'** + String get searchMessagesClearButtonTooltip; + /// Title for the page with unreads. /// /// In en, this message translates to: /// **'Inbox'** String get inboxPageTitle; + /// Centered text on the 'Inbox' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'** + String get inboxEmptyPlaceholder; + /// Title for the page with a list of DM conversations. /// /// In en, this message translates to: @@ -1028,6 +1251,12 @@ abstract class ZulipLocalizations { /// **'Direct messages'** String get recentDmConversationsSectionHeader; + /// Centered text on the 'Direct messages' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You have no direct messages yet! Why not start the conversation?'** + String get recentDmConversationsEmptyPlaceholder; + /// Page title for the 'Combined feed' message view. /// /// In en, this message translates to: @@ -1052,12 +1281,24 @@ abstract class ZulipLocalizations { /// **'Channels'** String get channelsPageTitle; + /// Centered text on the 'Channels' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You are not subscribed to any channels yet.'** + String get channelsEmptyPlaceholder; + /// Label for main-menu button leading to the user's own profile. /// /// In en, this message translates to: /// **'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: @@ -1082,12 +1323,6 @@ abstract class ZulipLocalizations { /// **'Unpinned'** String get unpinnedSubscriptionsLabel; - /// Text to display on subscribed-channels page when there are no subscribed channels. - /// - /// In en, this message translates to: - /// **'No channels found'** - String get subscriptionListNoChannels; - /// Display name for the user themself, to show after replying in an Android notification /// /// In en, this message translates to: @@ -1184,6 +1419,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: @@ -1232,6 +1473,72 @@ abstract class ZulipLocalizations { /// **'This poll has no options yet.'** String get pollWidgetOptionsMissing; + /// Title of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Open message feeds at'** + String get initialAnchorSettingTitle; + + /// Description of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'You can choose whether message feeds open at your first unread message or at the newest messages.'** + String get initialAnchorSettingDescription; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message'** + String get initialAnchorSettingFirstUnreadAlways; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message in conversation views, newest message elsewhere'** + String get initialAnchorSettingFirstUnreadConversations; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Newest message'** + String get initialAnchorSettingNewestAlways; + + /// Title of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Mark messages as read on scroll'** + String get markReadOnScrollSettingTitle; + + /// Description of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'When scrolling through messages, should they automatically be marked as read?'** + String get markReadOnScrollSettingDescription; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Always'** + String get markReadOnScrollSettingAlways; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Never'** + String get markReadOnScrollSettingNever; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Only in conversation views'** + String get markReadOnScrollSettingConversations; + + /// Description for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'** + String get markReadOnScrollSettingConversationsDescription; + /// Title of settings page for experimental, in-development features /// /// In en, this message translates to: @@ -1250,11 +1557,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 /// @@ -1286,6 +1593,18 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; + /// Label for the button revealing hidden message from a muted sender in message list. + /// + /// In en, this message translates to: + /// **'Reveal message'** + String get revealButtonLabel; + + /// Text to display in place of a muted user's name. + /// + /// In en, this message translates to: + /// **'Muted user'** + String get mutedUser; + /// Tooltip for button to scroll to bottom. /// /// In en, this message translates to: @@ -1319,13 +1638,17 @@ class _ZulipLocalizationsDelegate @override bool isSupported(Locale locale) => [ 'ar', + 'de', 'en', + 'it', 'ja', 'nb', 'pl', 'ru', 'sk', + 'sl', 'uk', + 'zh', ].contains(locale.languageCode); @override @@ -1333,12 +1656,36 @@ 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': + { + switch (locale.countryCode) { + case 'GB': + return ZulipLocalizationsEnGb(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'ar': return ZulipLocalizationsAr(); + case 'de': + return ZulipLocalizationsDe(); case 'en': return ZulipLocalizationsEn(); + case 'it': + return ZulipLocalizationsIt(); case 'ja': return ZulipLocalizationsJa(); case 'nb': @@ -1349,8 +1696,12 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsRu(); case 'sk': return ZulipLocalizationsSk(); + case 'sl': + return ZulipLocalizationsSl(); 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 8d36fa6bd0..22e1f04fc4 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; @@ -76,6 +90,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,11 +127,14 @@ 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'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -122,6 +142,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +164,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +214,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @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'; @@ -262,6 +288,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +308,43 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -291,6 +357,24 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @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'; @@ -307,6 +391,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -316,6 +403,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @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…'; @@ -342,6 +434,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; @@ -454,7 +552,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -475,7 +573,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -555,15 +653,32 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -576,9 +691,16 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -599,9 +721,6 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -654,6 +773,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -680,6 +802,44 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; @@ -691,8 +851,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'; @@ -709,6 +869,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @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 new file mode 100644 index 0000000000..74e5402693 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -0,0 +1,914 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class ZulipLocalizationsDe extends ZulipLocalizations { + ZulipLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get aboutPageTitle => 'Über Zulip'; + + @override + String get aboutPageAppVersion => 'App-Version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-Source-Lizenzen'; + + @override + String get aboutPageTapToView => 'Antippen zum Ansehen'; + + @override + String get upgradeWelcomeDialogTitle => 'Willkommen bei der neuen Zulip-App!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Sieh dir den Ankündigungs-Blogpost an!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Los gehts'; + + @override + String get chooseAccountPageTitle => 'Konto auswählen'; + + @override + String get settingsPageTitle => 'Einstellungen'; + + @override + String get switchAccountButton => 'Konto wechseln'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Dein Account bei $url benötigt einige Zeit zum Laden.'; + } + + @override + String get tryAnotherAccountButton => 'Anderen Account ausprobieren'; + + @override + String get chooseAccountPageLogOutButton => 'Abmelden'; + + @override + String get logOutConfirmationDialogTitle => 'Abmelden?'; + + @override + String get logOutConfirmationDialogMessage => + 'Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Abmelden'; + + @override + String get chooseAccountButtonAddAnAccount => 'Account hinzufügen'; + + @override + String get profileButtonSendDirectMessage => 'Direktnachricht senden'; + + @override + String get errorCouldNotShowUserProfile => + 'Nutzerprofil kann nicht angezeigt werden.'; + + @override + String get permissionsNeededTitle => 'Berechtigungen erforderlich'; + + @override + String get permissionsNeededOpenSettings => 'Einstellungen öffnen'; + + @override + String get permissionsDeniedCameraAccess => + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.'; + + @override + String get actionSheetOptionMarkChannelAsRead => + 'Kanal als gelesen markieren'; + + @override + String get actionSheetOptionListOfTopics => 'Themenliste'; + + @override + String get actionSheetOptionMuteTopic => 'Thema stummschalten'; + + @override + String get actionSheetOptionUnmuteTopic => 'Thema lautschalten'; + + @override + String get actionSheetOptionFollowTopic => 'Thema folgen'; + + @override + String get actionSheetOptionUnfollowTopic => 'Thema entfolgen'; + + @override + String get actionSheetOptionResolveTopic => 'Als gelöst markieren'; + + @override + String get actionSheetOptionUnresolveTopic => 'Als ungelöst markieren'; + + @override + String get errorResolveTopicFailedTitle => + 'Thema konnte nicht als gelöst markiert werden'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Thema konnte nicht als ungelöst markiert werden'; + + @override + String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; + + @override + String get actionSheetOptionCopyMessageLink => 'Link zur Nachricht kopieren'; + + @override + String get actionSheetOptionMarkAsUnread => 'Ab hier als ungelesen markieren'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Stummgeschaltete Nachricht wieder ausblenden'; + + @override + String get actionSheetOptionShare => 'Teilen'; + + @override + String get actionSheetOptionQuoteMessage => 'Nachricht zitieren'; + + @override + String get actionSheetOptionStarMessage => 'Nachricht markieren'; + + @override + String get actionSheetOptionUnstarMessage => 'Markierung aufheben'; + + @override + String get actionSheetOptionEditMessage => 'Nachricht bearbeiten'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Etwas ist schiefgelaufen'; + + @override + String get errorWebAuthOperationalError => + 'Ein unerwarteter Fehler ist aufgetreten.'; + + @override + String get errorAccountLoggedInTitle => 'Account bereits angemeldet'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Der Account $email auf $server ist bereits in deiner Account-Liste.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Konnte Nachrichtenquelle nicht abrufen.'; + + @override + String get errorCopyingFailed => 'Kopieren fehlgeschlagen'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Fehler beim Upload der Datei: $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 Dateien sind', + one: 'Datei ist', + ); + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num werden', + one: 'wird', + ); + return '$_temp0 größer als das Serverlimit von $maxFileUploadSizeMib MiB und $_temp1 nicht hochgeladen:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Dateien', + one: 'Datei', + ); + return '$_temp0 zu groß'; + } + + @override + String get errorLoginInvalidInputTitle => 'Ungültige Eingabe'; + + @override + String get errorLoginFailedTitle => 'Anmeldung fehlgeschlagen'; + + @override + String get errorMessageNotSent => 'Nachricht nicht versendet'; + + @override + String get errorMessageEditNotSaved => 'Nachricht nicht gespeichert'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Verbindung zu Server fehlgeschlagen:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Konnte nicht verbinden'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Diese Nachricht scheint nicht zu existieren.'; + + @override + String get errorQuotationFailed => 'Zitat fehlgeschlagen'; + + @override + String errorServerMessage(String message) { + return 'Der Server sagte:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Fehler beim Verbinden mit Zulip. Wiederhole…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Fehler beim Verbinden mit Zulip auf $serverUrl. Wird wiederholt:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Fehler beim Verarbeiten eines Zulip-Ereignisses von $serverUrl; Wird wiederholt.\n\nFehler: $error\n\nEreignis: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Link kann nicht geöffnet werden'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link konnte nicht geöffnet werden: $url'; + } + + @override + String get errorMuteTopicFailed => 'Konnte Thema nicht stummschalten'; + + @override + String get errorUnmuteTopicFailed => 'Konnte Thema nicht lautschalten'; + + @override + String get errorFollowTopicFailed => 'Konnte Thema nicht folgen'; + + @override + String get errorUnfollowTopicFailed => 'Konnte Thema nicht entfolgen'; + + @override + String get errorSharingFailed => 'Teilen fehlgeschlagen'; + + @override + String get errorStarMessageFailedTitle => 'Konnte Nachricht nicht markieren'; + + @override + String get errorUnstarMessageFailedTitle => + 'Konnte Markierung nicht von der Nachricht entfernen'; + + @override + String get errorCouldNotEditMessageTitle => + 'Konnte Nachricht nicht bearbeiten'; + + @override + String get successLinkCopied => 'Link kopiert'; + + @override + String get successMessageTextCopied => 'Nachrichtentext kopiert'; + + @override + String get successMessageLinkCopied => 'Nachrichtenlink kopiert'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Du hast keine Berechtigung in diesen Kanal zu schreiben.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Nachricht bearbeiten'; + + @override + String get composeBoxBannerButtonCancel => 'Abbrechen'; + + @override + String get composeBoxBannerButtonSave => 'Speichern'; + + @override + String get editAlreadyInProgressTitle => 'Kann Nachricht nicht bearbeiten'; + + @override + String get editAlreadyInProgressMessage => + 'Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.'; + + @override + String get savingMessageEditLabel => 'SPEICHERE BEARBEITUNG…'; + + @override + String get savingMessageEditFailedLabel => 'BEARBEITUNG NICHT GESPEICHERT'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Die Nachricht, die du schreibst, verwerfen?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Verwerfen'; + + @override + String get composeBoxAttachFilesTooltip => 'Dateien anhängen'; + + @override + String get composeBoxAttachMediaTooltip => 'Bilder oder Videos anhängen'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Ein Foto aufnehmen'; + + @override + String get composeBoxGenericContentHint => 'Eine Nachricht eingeben'; + + @override + String get newDmSheetComposeButtonLabel => 'Verfassen'; + + @override + String get newDmSheetScreenTitle => 'Neue DN'; + + @override + String get newDmFabButtonLabel => 'Neue DN'; + + @override + String get newDmSheetSearchHintEmpty => + 'Füge ein oder mehrere Nutzer:innen hinzu'; + + @override + String get newDmSheetSearchHintSomeSelected => + 'Füge weitere Nutzer:in hinzu…'; + + @override + String get newDmSheetNoUsersFound => 'Keine Nutzer:innen gefunden'; + + @override + String composeBoxDmContentHint(String user) { + return 'Nachricht an @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Nachricht an Gruppe'; + + @override + String get composeBoxSelfDmContentHint => 'Schreibe etwas'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Nachricht an $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Bereite vor…'; + + @override + String get composeBoxSendTooltip => 'Senden'; + + @override + String get unknownChannelName => '(unbekannter Kanal)'; + + @override + String get composeBoxTopicHintText => 'Thema'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Gib ein Thema ein (leer lassen für “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Lade $filename hoch…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(lade Nachricht $messageId)'; + } + + @override + String get unknownUserName => '(Nutzer:in unbekannt)'; + + @override + String get dmsWithYourselfPageTitle => 'DNs mit dir selbst'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Du und $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DNs mit $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Nachrichten mit dir selbst'; + + @override + String get contentValidationErrorTooLong => + 'Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.'; + + @override + String get contentValidationErrorEmpty => 'Du hast nichts zum Senden!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Bitte warte bis das Zitat abgeschlossen ist.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Bitte warte bis das Hochladen abgeschlossen ist.'; + + @override + String get dialogCancel => 'Abbrechen'; + + @override + String get dialogContinue => 'Fortsetzen'; + + @override + String get dialogClose => 'Schließen'; + + @override + String get errorDialogLearnMore => 'Mehr erfahren'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Fehler'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Link kopieren'; + + @override + String get lightboxVideoCurrentPosition => 'Aktuelle Position'; + + @override + String get lightboxVideoDuration => 'Videolänge'; + + @override + String get loginPageTitle => 'Anmelden'; + + @override + String get loginFormSubmitLabel => 'Anmelden'; + + @override + String get loginMethodDivider => 'ODER'; + + @override + String signInWithFoo(String method) { + return 'Anmelden mit $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Account hinzufügen'; + + @override + String get loginServerUrlLabel => 'Deine Zulip Server URL'; + + @override + String get loginHidePassword => 'Passwort verstecken'; + + @override + String get loginEmailLabel => 'E-Mail-Adresse'; + + @override + String get loginErrorMissingEmail => 'Bitte gib deine E-Mail ein.'; + + @override + String get loginPasswordLabel => 'Passwort'; + + @override + String get loginErrorMissingPassword => 'Bitte gib dein Passwort ein.'; + + @override + String get loginUsernameLabel => 'Benutzername'; + + @override + String get loginErrorMissingUsername => 'Bitte gib deinen Benutzernamen ein.'; + + @override + String get topicValidationErrorTooLong => + 'Länge des Themas sollte 60 Zeichen nicht überschreiten.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Themen sind in dieser Organisation erforderlich.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url nutzt Zulip Server $zulipVersion, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Dein Account bei $url konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.'; + } + + @override + String get errorInvalidResponse => + 'Der Server hat eine ungültige Antwort gesendet.'; + + @override + String get errorNetworkRequestFailed => 'Netzwerkanfrage fehlgeschlagen'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Netzwerkanfrage fehlgeschlagen: HTTP Status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => + 'Video konnte nicht wiedergegeben werden.'; + + @override + String get serverUrlValidationErrorEmpty => 'Bitte gib eine URL ein.'; + + @override + String get serverUrlValidationErrorInvalidUrl => + 'Bitte gib eine gültige URL ein.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'Die Server-URL muss mit http:// oder https:// beginnen.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Alle Nachrichten als gelesen markieren'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Nachrichten', + one: 'Eine Nachricht', + ); + return '$_temp0 als gelesen markiert.'; + } + + @override + String get markAsReadInProgress => 'Nachrichten werden als gelesen markiert…'; + + @override + String get errorMarkAsReadFailedTitle => + 'Als gelesen markieren fehlgeschlagen'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Nachrichten', + one: 'Eine Nachricht', + ); + return '$_temp0 als ungelesen markiert.'; + } + + @override + String get markAsUnreadInProgress => + 'Nachrichten werden als ungelesen markiert…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Als ungelesen markieren fehlgeschlagen'; + + @override + String get today => 'Heute'; + + @override + String get yesterday => 'Gestern'; + + @override + String get userRoleOwner => 'Besitzer'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Mitglied'; + + @override + String get userRoleGuest => 'Gast'; + + @override + String get userRoleUnknown => 'Unbekannt'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Eingang'; + + @override + String get inboxEmptyPlaceholder => + 'Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten um den kombinierten Feed oder die Kanalliste anzusehen.'; + + @override + String get recentDmConversationsPageTitle => 'Direktnachrichten'; + + @override + String get recentDmConversationsSectionHeader => 'Direktnachrichten'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?'; + + @override + String get combinedFeedPageTitle => 'Kombinierter Feed'; + + @override + String get mentionsPageTitle => 'Erwähnungen'; + + @override + String get starredMessagesPageTitle => 'Markierte Nachrichten'; + + @override + String get channelsPageTitle => 'Kanäle'; + + @override + String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.'; + + @override + String get mainMenuMyProfile => 'Mein Profil'; + + @override + String get topicsButtonLabel => 'THEMEN'; + + @override + String get channelFeedButtonTooltip => 'Kanal-Feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers weitere', + one: '1 weitere:n', + ); + return '$senderFullName an dich und $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Angeheftet'; + + @override + String get unpinnedSubscriptionsLabel => 'Nicht angeheftet'; + + @override + String get notifSelfUser => 'Du'; + + @override + String get reactedEmojiSelfUser => 'Du'; + + @override + String onePersonTyping(String typist) { + return '$typist tippt…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist und $otherTypist tippen…'; + } + + @override + String get manyPeopleTyping => 'Mehrere Leute tippen…'; + + @override + String get wildcardMentionAll => 'alle'; + + @override + String get wildcardMentionEveryone => 'jeder'; + + @override + String get wildcardMentionChannel => 'Kanal'; + + @override + String get wildcardMentionStream => 'Stream'; + + @override + String get wildcardMentionTopic => 'Thema'; + + @override + String get wildcardMentionChannelDescription => 'Kanal benachrichtigen'; + + @override + String get wildcardMentionStreamDescription => 'Stream benachrichtigen'; + + @override + String get wildcardMentionAllDmDescription => 'Empfänger benachrichtigen'; + + @override + String get wildcardMentionTopicDescription => 'Thema benachrichtigen'; + + @override + String get messageIsEditedLabel => 'BEARBEITET'; + + @override + String get messageIsMovedLabel => 'VERSCHOBEN'; + + @override + String get messageNotSentLabel => 'NACHRICHT NICHT GESENDET'; + + @override + String pollVoterNames(String voterNames) { + return '$voterNames'; + } + + @override + String get themeSettingTitle => 'THEMA'; + + @override + String get themeSettingDark => 'Dunkel'; + + @override + String get themeSettingLight => 'Hell'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Links mit In-App-Browser öffnen'; + + @override + String get pollWidgetQuestionMissing => 'Keine Frage.'; + + @override + String get pollWidgetOptionsMissing => + 'Diese Umfrage hat noch keine Optionen.'; + + @override + String get initialAnchorSettingTitle => 'Nachrichten-Feed öffnen bei'; + + @override + String get initialAnchorSettingDescription => + 'Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Erste ungelesene Nachricht'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht'; + + @override + String get initialAnchorSettingNewestAlways => 'Neueste Nachricht'; + + @override + String get markReadOnScrollSettingTitle => + 'Nachrichten beim Scrollen als gelesen markieren'; + + @override + String get markReadOnScrollSettingDescription => + 'Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?'; + + @override + String get markReadOnScrollSettingAlways => 'Immer'; + + @override + String get markReadOnScrollSettingNever => 'Nie'; + + @override + String get markReadOnScrollSettingConversations => + 'Nur in Unterhaltungsansichten'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.'; + + @override + String get experimentalFeatureSettingsPageTitle => + 'Experimentelle Funktionen'; + + @override + String get experimentalFeatureSettingsWarning => + 'Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.'; + + @override + String get errorNotificationOpenTitle => + 'Fehler beim Öffnen der Benachrichtigung'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.'; + + @override + String get errorReactionAddingFailedTitle => + 'Hinzufügen der Reaktion fehlgeschlagen'; + + @override + String get errorReactionRemovingFailedTitle => + 'Entfernen der Reaktion fehlgeschlagen'; + + @override + String get emojiReactionsMore => 'mehr'; + + @override + String get emojiPickerSearchEmoji => 'Emoji suchen'; + + @override + String get noEarlierMessages => 'Keine früheren Nachrichten'; + + @override + String get revealButtonLabel => + 'Nachricht für stummgeschalteten Absender anzeigen'; + + @override + String get mutedUser => 'Stummgeschaltete:r Nutzer:in'; + + @override + String get scrollToBottomTooltip => 'Nach unten Scrollen'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a74a2e1eaf..cad9183a94 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; @@ -76,6 +90,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,11 +127,14 @@ 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'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -122,6 +142,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +164,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +214,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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'; @@ -262,6 +288,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +308,43 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -291,6 +357,24 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @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'; @@ -307,6 +391,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -316,6 +403,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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…'; @@ -342,6 +434,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; @@ -454,7 +552,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -475,7 +573,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -555,15 +653,32 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -576,9 +691,16 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -599,9 +721,6 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -654,6 +773,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -680,6 +802,44 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; @@ -691,8 +851,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'; @@ -709,6 +869,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; @@ -718,3 +884,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; } + +/// The translations for English, as used in the United Kingdom (`en_GB`). +class ZulipLocalizationsEnGb extends ZulipLocalizationsEn { + ZulipLocalizationsEnGb() : super('en_GB'); + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organisation.'; +} diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart new file mode 100644 index 0000000000..5f85cce756 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -0,0 +1,908 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class ZulipLocalizationsIt extends ZulipLocalizations { + ZulipLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get aboutPageTitle => 'Su Zulip'; + + @override + String get aboutPageAppVersion => 'Versione app'; + + @override + String get aboutPageOpenSourceLicenses => 'Licenze open-source'; + + @override + String get aboutPageTapToView => 'Tap per visualizzare'; + + @override + String get upgradeWelcomeDialogTitle => 'Benvenuti alla nuova app Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Troverai un\'esperienza familiare in un pacchetto più veloce ed elegante.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Date un\'occhiata al post dell\'annuncio sul blog!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Andiamo'; + + @override + String get chooseAccountPageTitle => 'Scegli account'; + + @override + String get settingsPageTitle => 'Impostazioni'; + + @override + String get switchAccountButton => 'Cambia account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Il caricamento dell\'account su $url sta richiedendo un po\' di tempo.'; + } + + @override + String get tryAnotherAccountButton => 'Prova un altro account'; + + @override + String get chooseAccountPageLogOutButton => 'Esci'; + + @override + String get logOutConfirmationDialogTitle => 'Disconnettersi?'; + + @override + String get logOutConfirmationDialogMessage => + 'Per utilizzare questo account in futuro, bisognerà reinserire l\'URL della propria organizzazione e le informazioni del proprio account.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Esci'; + + @override + String get chooseAccountButtonAddAnAccount => 'Aggiungi un account'; + + @override + String get profileButtonSendDirectMessage => 'Invia un messaggio diretto'; + + @override + String get errorCouldNotShowUserProfile => + 'Impossibile mostrare il profilo utente.'; + + @override + String get permissionsNeededTitle => 'Permessi necessari'; + + @override + String get permissionsNeededOpenSettings => 'Apri le impostazioni'; + + @override + String get permissionsDeniedCameraAccess => + 'Per caricare un\'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Segna il canale come letto'; + + @override + String get actionSheetOptionListOfTopics => 'Elenco degli argomenti'; + + @override + String get actionSheetOptionMuteTopic => 'Silenzia argomento'; + + @override + String get actionSheetOptionUnmuteTopic => 'Riattiva argomento'; + + @override + String get actionSheetOptionFollowTopic => 'Segui argomento'; + + @override + String get actionSheetOptionUnfollowTopic => 'Non seguire più l\'argomento'; + + @override + String get actionSheetOptionResolveTopic => 'Segna come risolto'; + + @override + String get actionSheetOptionUnresolveTopic => 'Segna come irrisolto'; + + @override + String get errorResolveTopicFailedTitle => + 'Impossibile contrassegnare l\'argomento come risolto'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Impossibile contrassegnare l\'argomento come irrisolto'; + + @override + String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Copia il collegamento al messaggio'; + + @override + String get actionSheetOptionMarkAsUnread => 'Segna come non letto da qui'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Nascondi nuovamente il messaggio disattivato'; + + @override + String get actionSheetOptionShare => 'Condividi'; + + @override + String get actionSheetOptionQuoteMessage => 'Cita messaggio'; + + @override + String get actionSheetOptionStarMessage => 'Messaggio speciale'; + + @override + String get actionSheetOptionUnstarMessage => 'Messaggio normale'; + + @override + String get actionSheetOptionEditMessage => 'Modifica messaggio'; + + @override + String get actionSheetOptionMarkTopicAsRead => + 'Segna l\'argomento come letto'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Qualcosa è andato storto'; + + @override + String get errorWebAuthOperationalError => + 'Si è verificato un errore imprevisto.'; + + @override + String get errorAccountLoggedInTitle => 'Account già registrato'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'L\'account $email su $server è già presente nell\'elenco account.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Impossibile recuperare l\'origine del messaggio.'; + + @override + String get errorCopyingFailed => 'Copia non riuscita'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Impossibile caricare il 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 file sono', + one: 'file è', + ); + return '$_temp0 più grande/i del limite del server di $maxFileUploadSizeMib MiB e non verrà/anno caricato/i:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'File', + one: 'File', + ); + return '$_temp0 troppo grande/i'; + } + + @override + String get errorLoginInvalidInputTitle => 'Ingresso non valido'; + + @override + String get errorLoginFailedTitle => 'Accesso non riuscito'; + + @override + String get errorMessageNotSent => 'Messaggio non inviato'; + + @override + String get errorMessageEditNotSaved => 'Messaggio non salvato'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Impossibile connettersi al server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Impossibile connettersi'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Quel messaggio sembra non esistere.'; + + @override + String get errorQuotationFailed => 'Citazione non riuscita'; + + @override + String errorServerMessage(String message) { + return 'Il server ha detto:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Errore di connessione a Zulip. Nuovo tentativo…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Errore durante la connessione a Zulip su $serverUrl. Verrà effettuato un nuovo tentativo:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Errore nella gestione di un evento Zulip da $serverUrl; verrà effettuato un nuovo tentativo.\n\nErrore: $error\n\nEvento: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Impossibile aprire il collegamento'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Impossibile aprire il collegamento: $url'; + } + + @override + String get errorMuteTopicFailed => 'Impossibile silenziare l\'argomento'; + + @override + String get errorUnmuteTopicFailed => 'Impossibile de-silenziare l\'argomento'; + + @override + String get errorFollowTopicFailed => 'Impossibile seguire l\'argomento'; + + @override + String get errorUnfollowTopicFailed => + 'Impossibile smettere di seguire l\'argomento'; + + @override + String get errorSharingFailed => 'Condivisione fallita'; + + @override + String get errorStarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come speciale'; + + @override + String get errorUnstarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come normale'; + + @override + String get errorCouldNotEditMessageTitle => + 'Impossibile modificare il messaggio'; + + @override + String get successLinkCopied => 'Collegamento copiato'; + + @override + String get successMessageTextCopied => 'Testo messaggio copiato'; + + @override + String get successMessageLinkCopied => 'Collegamento messaggio copiato'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Non è possibile inviare messaggi agli utenti disattivati.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Non hai l\'autorizzazione per postare su questo canale.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Modifica messaggio'; + + @override + String get composeBoxBannerButtonCancel => 'Annulla'; + + @override + String get composeBoxBannerButtonSave => 'Salva'; + + @override + String get editAlreadyInProgressTitle => + 'Impossibile modificare il messaggio'; + + @override + String get editAlreadyInProgressMessage => + 'Una modifica è già in corso. Attendere il completamento.'; + + @override + String get savingMessageEditLabel => 'SALVATAGGIO MODIFICA…'; + + @override + String get savingMessageEditFailedLabel => 'MODIFICA NON SALVATA'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Scartare il messaggio che si sta scrivendo?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Abbandona'; + + @override + String get composeBoxAttachFilesTooltip => 'Allega file'; + + @override + String get composeBoxAttachMediaTooltip => 'Allega immagini o video'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Fai una foto'; + + @override + String get composeBoxGenericContentHint => 'Batti un messaggio'; + + @override + String get newDmSheetComposeButtonLabel => 'Componi'; + + @override + String get newDmSheetScreenTitle => 'Nuovo MD'; + + @override + String get newDmFabButtonLabel => 'Nuovo MD'; + + @override + String get newDmSheetSearchHintEmpty => 'Aggiungi uno o più utenti'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Aggiungi un altro utente…'; + + @override + String get newDmSheetNoUsersFound => 'Nessun utente trovato'; + + @override + String composeBoxDmContentHint(String user) { + return 'Messaggia @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Gruppo di messaggi'; + + @override + String get composeBoxSelfDmContentHint => 'Annota qualcosa'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Messaggia $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparazione…'; + + @override + String get composeBoxSendTooltip => 'Invia'; + + @override + String get unknownChannelName => '(canale sconosciuto)'; + + @override + String get composeBoxTopicHintText => 'Argomento'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Inserisci un argomento (salta per \"$defaultTopicName\")'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Caricamento $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(caricamento messaggio $messageId)'; + } + + @override + String get unknownUserName => '(utente sconosciuto)'; + + @override + String get dmsWithYourselfPageTitle => 'MD con te stesso'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Tu e $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'MD con $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Messaggi con te stesso'; + + @override + String get contentValidationErrorTooLong => + 'La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.'; + + @override + String get contentValidationErrorEmpty => 'Non devi inviare nulla!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Attendere il completamento del commento.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Attendere il completamento del caricamento.'; + + @override + String get dialogCancel => 'Annulla'; + + @override + String get dialogContinue => 'Continua'; + + @override + String get dialogClose => 'Chiudi'; + + @override + String get errorDialogLearnMore => 'Scopri di più'; + + @override + String get errorDialogContinue => 'Ok'; + + @override + String get errorDialogTitle => 'Errore'; + + @override + String get snackBarDetails => 'Dettagli'; + + @override + String get lightboxCopyLinkTooltip => 'Copia collegamento'; + + @override + String get lightboxVideoCurrentPosition => 'Posizione corrente'; + + @override + String get lightboxVideoDuration => 'Durata video'; + + @override + String get loginPageTitle => 'Accesso'; + + @override + String get loginFormSubmitLabel => 'Accesso'; + + @override + String get loginMethodDivider => 'O'; + + @override + String signInWithFoo(String method) { + return 'Accedi con $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Aggiungi account'; + + @override + String get loginServerUrlLabel => 'URL del server Zulip'; + + @override + String get loginHidePassword => 'Nascondi password'; + + @override + String get loginEmailLabel => 'Indirizzo email'; + + @override + String get loginErrorMissingEmail => 'Inserire l\'email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Inserire la propria password.'; + + @override + String get loginUsernameLabel => 'Nomeutente'; + + @override + String get loginErrorMissingUsername => 'Inserire il proprio nomeutente.'; + + @override + String get topicValidationErrorTooLong => + 'La lunghezza dell\'argomento non deve superare i 60 caratteri.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'In questa organizzazione sono richiesti degli argomenti.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url sta usando Zulip Server $zulipVersion, che non è supportato. La versione minima supportata è Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'L\'account su $url non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.'; + } + + @override + String get errorInvalidResponse => + 'Il server ha inviato una risposta non valida.'; + + @override + String get errorNetworkRequestFailed => 'Richiesta di rete non riuscita'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Richiesta di rete non riuscita: stato HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Impossibile riprodurre il video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Inserire un URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Inserire un URL valido.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Inserire l\'URL del server, non il proprio indirizzo email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'L\'URL del server deve iniziare con http:// o https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Segna tutti i messaggi come letti'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messagei', + one: '1 messaggio', + ); + return 'Segnato/i $_temp0 come letto/i.'; + } + + @override + String get markAsReadInProgress => 'Contrassegno dei messaggi come letti…'; + + @override + String get errorMarkAsReadFailedTitle => + 'Contrassegno come letto non riuscito'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messagi', + one: '1 messaggio', + ); + return 'Segnato/i $_temp0 come non letto/i.'; + } + + @override + String get markAsUnreadInProgress => + 'Contrassegno dei messaggi come non letti…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Contrassegno come non letti non riuscito'; + + @override + String get today => 'Oggi'; + + @override + String get yesterday => 'Ieri'; + + @override + String get userRoleOwner => 'Proprietario'; + + @override + String get userRoleAdministrator => 'Amministratore'; + + @override + String get userRoleModerator => 'Moderatore'; + + @override + String get userRoleMember => 'Membro'; + + @override + String get userRoleGuest => 'Ospite'; + + @override + String get userRoleUnknown => 'Sconosciuto'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l\'elenco dei canali.'; + + @override + String get recentDmConversationsPageTitle => 'Messaggi diretti'; + + @override + String get recentDmConversationsSectionHeader => 'Messaggi diretti'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?'; + + @override + String get combinedFeedPageTitle => 'Feed combinato'; + + @override + String get mentionsPageTitle => 'Menzioni'; + + @override + String get starredMessagesPageTitle => 'Messaggi speciali'; + + @override + String get channelsPageTitle => 'Canali'; + + @override + String get channelsEmptyPlaceholder => + 'Non sei ancora iscritto ad alcun canale.'; + + @override + String get mainMenuMyProfile => 'Il mio profilo'; + + @override + String get topicsButtonLabel => 'ARGOMENTI'; + + @override + String get channelFeedButtonTooltip => 'Feed del canale'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers altri', + one: '1 altro', + ); + return '$senderFullName a te e $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Bloccato'; + + @override + String get unpinnedSubscriptionsLabel => 'Non bloccato'; + + @override + String get notifSelfUser => 'Tu'; + + @override + String get reactedEmojiSelfUser => 'Tu'; + + @override + String onePersonTyping(String typist) { + return '$typist sta scrivendo…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist e $otherTypist stanno scrivendo…'; + } + + @override + String get manyPeopleTyping => 'Molte persone stanno scrivendo…'; + + @override + String get wildcardMentionAll => 'tutti'; + + @override + String get wildcardMentionEveryone => 'ognuno'; + + @override + String get wildcardMentionChannel => 'canale'; + + @override + String get wildcardMentionStream => 'flusso'; + + @override + String get wildcardMentionTopic => 'argomento'; + + @override + String get wildcardMentionChannelDescription => 'Notifica canale'; + + @override + String get wildcardMentionStreamDescription => 'Notifica flusso'; + + @override + String get wildcardMentionAllDmDescription => 'Notifica destinatari'; + + @override + String get wildcardMentionTopicDescription => 'Notifica argomento'; + + @override + String get messageIsEditedLabel => 'MODIFICATO'; + + @override + String get messageIsMovedLabel => 'SPOSTATO'; + + @override + String get messageNotSentLabel => 'MESSAGGIO NON INVIATO'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'TEMA'; + + @override + String get themeSettingDark => 'Scuro'; + + @override + String get themeSettingLight => 'Chiaro'; + + @override + String get themeSettingSystem => 'Sistema'; + + @override + String get openLinksWithInAppBrowser => + 'Apri i collegamenti con il browser in-app'; + + @override + String get pollWidgetQuestionMissing => 'Nessuna domanda.'; + + @override + String get pollWidgetOptionsMissing => + 'Questo sondaggio non ha ancora opzioni.'; + + @override + String get initialAnchorSettingTitle => 'Apri i feed dei messaggi su'; + + @override + String get initialAnchorSettingDescription => + 'È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Primo messaggio non letto'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove'; + + @override + String get initialAnchorSettingNewestAlways => 'Messaggio più recente'; + + @override + String get markReadOnScrollSettingTitle => + 'Segna i messaggi come letti durante lo scorrimento'; + + @override + String get markReadOnScrollSettingDescription => + 'Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?'; + + @override + String get markReadOnScrollSettingAlways => 'Sempre'; + + @override + String get markReadOnScrollSettingNever => 'Mai'; + + @override + String get markReadOnScrollSettingConversations => + 'Solo nelle visualizzazioni delle conversazioni'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.'; + + @override + String get experimentalFeatureSettingsPageTitle => + 'Caratteristiche sperimentali'; + + @override + String get experimentalFeatureSettingsWarning => + 'Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell\'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Impossibile aprire la notifica'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Impossibile trovare l\'account associato a questa notifica.'; + + @override + String get errorReactionAddingFailedTitle => + 'Aggiunta della reazione non riuscita'; + + @override + String get errorReactionRemovingFailedTitle => + 'Rimozione della reazione non riuscita'; + + @override + String get emojiReactionsMore => 'altro'; + + @override + String get emojiPickerSearchEmoji => 'Cerca emoji'; + + @override + String get noEarlierMessages => 'Nessun messaggio precedente'; + + @override + String get revealButtonLabel => 'Mostra messaggio per mittente silenziato'; + + @override + String get mutedUser => 'Utente silenziato'; + + @override + String get scrollToBottomTooltip => 'Scorri fino in fondo'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c11a3fae23..b2eb5fc7b0 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'アカウントを選択'; @@ -76,6 +90,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,11 +127,14 @@ 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'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -122,6 +142,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +164,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +214,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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'; @@ -262,6 +288,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +308,43 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -291,6 +357,24 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @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'; @@ -307,6 +391,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -316,6 +403,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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…'; @@ -342,6 +434,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; @@ -454,7 +552,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -475,7 +573,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -555,15 +653,32 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -576,9 +691,16 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -599,9 +721,6 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -654,6 +773,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -680,6 +802,44 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; @@ -691,8 +851,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'; @@ -709,6 +869,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @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 d23bd323fd..8744729a26 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; @@ -76,6 +90,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,11 +127,14 @@ 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'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -122,6 +142,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +164,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +214,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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'; @@ -262,6 +288,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +308,43 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -291,6 +357,24 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @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'; @@ -307,6 +391,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -316,6 +403,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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…'; @@ -342,6 +434,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; @@ -454,7 +552,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -475,7 +573,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -555,15 +653,32 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -576,9 +691,16 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -599,9 +721,6 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -654,6 +773,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -680,6 +802,44 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; @@ -691,8 +851,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'; @@ -709,6 +869,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @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 e1a6bd45f4..711f1c5760 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get aboutPageTapToView => 'Dotknij, aby pokazać'; + @override + String get upgradeWelcomeDialogTitle => 'Witaj w nowej apce Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Sprawdź blog pod kątem obwieszczenia!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Zaczynajmy'; + @override String get chooseAccountPageTitle => 'Wybierz konto'; @@ -35,7 +49,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get tryAnotherAccountButton => 'Sprawdź inne konto'; + String get tryAnotherAccountButton => 'Użyj innego konta'; @override String get chooseAccountPageLogOutButton => 'Wyloguj'; @@ -45,7 +59,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get logOutConfirmationDialogMessage => - 'Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.'; + 'Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.'; @override String get logOutConfirmationDialogConfirmButton => 'Wyloguj'; @@ -78,6 +92,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,11 +132,15 @@ 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'; @override - String get actionSheetOptionQuoteAndReply => 'Odpowiedz cytując'; + String get actionSheetOptionQuoteMessage => 'Cytuj wiadomość'; @override String get actionSheetOptionStarMessage => 'Oznacz gwiazdką'; @@ -127,6 +148,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get actionSheetOptionEditMessage => 'Zmień wiadomość'; + @override String get actionSheetOptionMarkTopicAsRead => 'Oznacz wątek jako przeczytany'; @@ -147,7 +171,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Nie można uzyskać źródłowej wiadomości'; + 'Nie można uzyskać źródłowej wiadomości.'; @override String get errorCopyingFailed => 'Nie udało się skopiować'; @@ -197,6 +221,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorMessageNotSent => 'Nie wysłano wiadomości'; + @override + String get errorMessageEditNotSaved => 'Nie zapisano wiadomości'; + @override String errorLoginCouldNotConnect(String url) { return 'Nie udało się połączyć z serwerem:\n$url'; @@ -269,6 +296,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Odebranie gwiazdki bez powodzenia'; + @override + String get errorCouldNotEditMessageTitle => 'Nie można zmienić wiadomości'; + @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -286,6 +316,43 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; + @override + String get composeBoxBannerLabelEditMessage => 'Zmień wiadomość'; + + @override + String get composeBoxBannerButtonCancel => 'Anuluj'; + + @override + String get composeBoxBannerButtonSave => 'Zapisz'; + + @override + String get editAlreadyInProgressTitle => 'Nie udało się zapisać zmiany'; + + @override + String get editAlreadyInProgressMessage => + 'Operacja zmiany w toku. Zaczekaj na jej zakończenie.'; + + @override + String get savingMessageEditLabel => 'ZAPIS ZMIANY…'; + + @override + String get savingMessageEditFailedLabel => 'NIE ZAPISANO ZMIANY'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Czy chcesz przerwać szykowanie wpisu?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; + @override String get composeBoxAttachFilesTooltip => 'Dołącz pliki'; @@ -298,6 +365,25 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; + @override + String get newDmSheetComposeButtonLabel => 'Utwórz'; + + @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'; @@ -314,6 +400,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Wiadomość do $destination'; } + @override + String get preparingEditMessageContentInput => 'Przygotowywanie…'; + @override String get composeBoxSendTooltip => 'Wyślij'; @@ -323,6 +412,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Wątek'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Wpisz tytuł wątku (pomiń aby uzyskać “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Przekazywanie $filename…'; @@ -349,6 +443,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'DM z $others'; } + @override + String get emptyMessageList => 'Póki co brak wiadomości.'; + + @override + String get emptyMessageListSearch => 'Brak wyników wyszukiwania.'; + @override String get messageListGroupYouWithYourself => 'Zapiski na własne konto'; @@ -461,7 +561,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera'; + String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera.'; @override String get errorNetworkRequestFailed => 'Dostęp do sieci bez powodzenia'; @@ -482,7 +582,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo'; + String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo.'; @override String get serverUrlValidationErrorEmpty => 'Proszę podaj URL.'; @@ -564,15 +664,32 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get userRoleUnknown => 'Nieznany'; + @override + String get searchMessagesPageTitle => 'Szukaj'; + + @override + String get searchMessagesHintText => 'Szukaj'; + + @override + String get searchMessagesClearButtonTooltip => 'Wyczyść'; + @override String get inboxPageTitle => 'Odebrane'; + @override + String get inboxEmptyPlaceholder => + 'Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.'; + @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; @override String get recentDmConversationsSectionHeader => 'Wiadomości bezpośrednie'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?'; + @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -585,9 +702,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanały'; + @override + String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.'; + @override String get mainMenuMyProfile => 'Mój profil'; + @override + String get topicsButtonLabel => 'WĄTKI'; + @override String get channelFeedButtonTooltip => 'Strumień kanału'; @@ -608,9 +731,6 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Odpięte'; - @override - String get subscriptionListNoChannels => 'Nie odnaleziono kanałów'; - @override String get notifSelfUser => 'Ty'; @@ -663,6 +783,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)'; @@ -689,6 +812,45 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'Ta sonda nie ma opcji do wyboru.'; + @override + String get initialAnchorSettingTitle => 'Pokaż wiadomości w porządku'; + + @override + String get initialAnchorSettingDescription => + 'Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Pierwsza nieprzeczytana wiadomość'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość'; + + @override + String get initialAnchorSettingNewestAlways => 'Najnowsza wiadomość'; + + @override + String get markReadOnScrollSettingTitle => + 'Oznacz wiadomości jako przeczytane przy przwijaniu'; + + @override + String get markReadOnScrollSettingDescription => + 'Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?'; + + @override + String get markReadOnScrollSettingAlways => 'Zawsze'; + + @override + String get markReadOnScrollSettingNever => 'Nigdy'; + + @override + String get markReadOnScrollSettingConversations => 'Tylko w widoku dyskusji'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.'; + @override String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne'; @@ -701,8 +863,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'; @@ -720,6 +882,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noEarlierMessages => 'Brak historii'; + @override + String get revealButtonLabel => 'Odsłoń wiadomość'; + + @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 78b68e812a..bcb3ffb130 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get aboutPageTapToView => 'Нажмите для просмотра'; + @override + String get upgradeWelcomeDialogTitle => + 'Добро пожаловать в новое приложение Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Вы найдете привычные возможности в более быстром и легком приложении.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Ознакомьтесь с анонсом в блоге!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Приступим!'; + @override String get chooseAccountPageTitle => 'Выберите учетную запись'; @@ -78,6 +92,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Отметить канал как прочитанный'; + @override + String get actionSheetOptionListOfTopics => 'Список тем'; + @override String get actionSheetOptionMuteTopic => 'Отключить тему'; @@ -115,11 +132,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Отметить как непрочитанные начиная отсюда'; + @override + String get actionSheetOptionHideMutedMessage => + 'Скрыть отключенное сообщение'; + @override String get actionSheetOptionShare => 'Поделиться'; @override - String get actionSheetOptionQuoteAndReply => 'Ответить с цитированием'; + String get actionSheetOptionQuoteMessage => 'Цитировать сообщение'; @override String get actionSheetOptionStarMessage => 'Отметить сообщение'; @@ -127,6 +148,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get actionSheetOptionEditMessage => 'Редактировать сообщение'; + @override String get actionSheetOptionMarkTopicAsRead => 'Отметить тему как прочитанную'; @@ -147,7 +171,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не удалось извлечь источник сообщения'; + 'Не удалось извлечь источник сообщения.'; @override String get errorCopyingFailed => 'Сбой копирования'; @@ -197,6 +221,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorMessageNotSent => 'Сообщение не отправлено'; + @override + String get errorMessageEditNotSaved => 'Сообщение не сохранено'; + @override String errorLoginCouldNotConnect(String url) { return 'Не удалось подключиться к серверу:\n$url'; @@ -270,6 +297,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Не удалось снять отметку с сообщения'; + @override + String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; + @override String get successLinkCopied => 'Ссылка скопирована'; @@ -287,6 +317,43 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'У вас нет права писать в этом канале.'; + @override + String get composeBoxBannerLabelEditMessage => 'Редактирование сообщения'; + + @override + String get composeBoxBannerButtonCancel => 'Отмена'; + + @override + String get composeBoxBannerButtonSave => 'Сохранить'; + + @override + String get editAlreadyInProgressTitle => 'Редактирование недоступно'; + + @override + String get editAlreadyInProgressMessage => + 'Редактирование уже выполняется. Дождитесь завершения.'; + + @override + String get savingMessageEditLabel => 'ЗАПИСЬ ПРАВОК…'; + + @override + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ СОХРАНЕНЫ'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Отказаться от написанного сообщения?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'При изменении сообщения текст из поля для редактирования удаляется.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'При восстановлении неотправленного сообщения содержимое поля редактирования очищается.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; + @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -299,6 +366,24 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести сообщение'; + @override + String get newDmSheetComposeButtonLabel => 'Написать'; + + @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'; @@ -315,6 +400,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Сообщение для $destination'; } + @override + String get preparingEditMessageContentInput => 'Подготовка…'; + @override String get composeBoxSendTooltip => 'Отправить'; @@ -324,6 +412,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Укажите тему (или оставьте “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Загрузка $filename…'; @@ -350,6 +443,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'ЛС с $others'; } + @override + String get emptyMessageList => 'Здесь нет сообщений.'; + + @override + String get emptyMessageListSearch => 'Ничего не найдено.'; + @override String get messageListGroupYouWithYourself => 'Сообщения с собой'; @@ -464,7 +563,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; + String get errorInvalidResponse => 'Сервер отправил недопустимый ответ.'; @override String get errorNetworkRequestFailed => 'Сбой сетевого запроса'; @@ -485,7 +584,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Не удается воспроизвести видео'; + String get errorVideoPlayerFailed => 'Не удается воспроизвести видео.'; @override String get serverUrlValidationErrorEmpty => 'Пожалуйста, введите URL-адрес.'; @@ -568,15 +667,32 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get userRoleUnknown => 'Неизвестно'; + @override + String get searchMessagesPageTitle => 'Поиск'; + + @override + String get searchMessagesHintText => 'Поиск'; + + @override + String get searchMessagesClearButtonTooltip => 'Очистить'; + @override String get inboxPageTitle => 'Входящие'; + @override + String get inboxEmptyPlaceholder => + 'Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.'; + @override String get recentDmConversationsPageTitle => 'Личные сообщения'; @override String get recentDmConversationsSectionHeader => 'Личные сообщения'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'У вас пока нет личных сообщений! Почему бы не начать беседу?'; + @override String get combinedFeedPageTitle => 'Объединенная лента'; @@ -589,9 +705,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get channelsEmptyPlaceholder => + 'Вы еще не подписаны ни на один канал.'; + @override String get mainMenuMyProfile => 'Мой профиль'; + @override + String get topicsButtonLabel => 'ТЕМЫ'; + @override String get channelFeedButtonTooltip => 'Лента канала'; @@ -612,9 +735,6 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Откреплены'; - @override - String get subscriptionListNoChannels => 'Каналы не найдены'; - @override String get notifSelfUser => 'Вы'; @@ -667,6 +787,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageNotSentLabel => 'СООБЩЕНИЕ НЕ ОТПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -693,6 +816,46 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'В опросе пока нет вариантов ответа.'; + @override + String get initialAnchorSettingTitle => 'Где открывать ленту сообщений'; + + @override + String get initialAnchorSettingDescription => + 'Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Первое непрочитанное сообщение'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах'; + + @override + String get initialAnchorSettingNewestAlways => 'Самое новое сообщение'; + + @override + String get markReadOnScrollSettingTitle => + 'Отмечать сообщения как прочитанные при прокрутке'; + + @override + String get markReadOnScrollSettingDescription => + 'При прокрутке сообщений автоматически отмечать их как прочитанные?'; + + @override + String get markReadOnScrollSettingAlways => 'Всегда'; + + @override + String get markReadOnScrollSettingNever => 'Никогда'; + + @override + String get markReadOnScrollSettingConversations => + 'Только при просмотре бесед'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.'; + @override String get experimentalFeatureSettingsPageTitle => 'Экспериментальные функции'; @@ -705,8 +868,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => - 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => + 'Учетная запись, связанная с этим уведомлением, не найдена.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; @@ -723,6 +886,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noEarlierMessages => 'Предшествующих сообщений нет'; + @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 193ac26d8e..24797fad32 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get aboutPageTapToView => 'Klepnutím zobraziť'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Zvoliť účet'; @@ -76,6 +90,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,11 +128,14 @@ 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ť'; @override - String get actionSheetOptionQuoteAndReply => 'Citovať a odpovedať'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Ohviezdičkovať správu'; @@ -123,6 +143,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -192,6 +215,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Správa nebola odoslaná'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nepodarilo sa pripojiť na server:\n$url'; @@ -262,6 +288,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +308,43 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -291,6 +357,24 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @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'; @@ -307,6 +391,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -316,6 +403,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @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…'; @@ -342,6 +434,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; @@ -557,15 +655,32 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznáma'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Priama správa'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Zlúčený kanál'; @@ -578,9 +693,16 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanály'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'Môj profil'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -601,9 +723,6 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'Ty'; @@ -656,6 +775,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -682,6 +804,44 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; @@ -693,8 +853,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'; @@ -711,6 +871,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart new file mode 100644 index 0000000000..c298af9351 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -0,0 +1,915 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Slovenian (`sl`). +class ZulipLocalizationsSl extends ZulipLocalizations { + ZulipLocalizationsSl([String locale = 'sl']) : super(locale); + + @override + String get aboutPageTitle => 'O Zulipu'; + + @override + String get aboutPageAppVersion => 'Različica aplikacije'; + + @override + String get aboutPageOpenSourceLicenses => 'Odprtokodne licence'; + + @override + String get aboutPageTapToView => 'Dotaknite se za ogled'; + + @override + String get upgradeWelcomeDialogTitle => 'Dobrodošli v novi aplikaciji Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Preberite objavo na blogu!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Začnimo'; + + @override + String get chooseAccountPageTitle => 'Izberite račun'; + + @override + String get settingsPageTitle => 'Nastavitve'; + + @override + String get switchAccountButton => 'Preklopi račun'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Nalaganje vašega računa na $url traja dlje kot običajno.'; + } + + @override + String get tryAnotherAccountButton => 'Poskusite z drugim računom'; + + @override + String get chooseAccountPageLogOutButton => 'Odjava'; + + @override + String get logOutConfirmationDialogTitle => 'Se želite odjaviti?'; + + @override + String get logOutConfirmationDialogMessage => + 'Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Odjavi se'; + + @override + String get chooseAccountButtonAddAnAccount => 'Dodaj račun'; + + @override + String get profileButtonSendDirectMessage => 'Pošlji neposredno sporočilo'; + + @override + String get errorCouldNotShowUserProfile => + 'Uporabniškega profila ni mogoče prikazati.'; + + @override + String get permissionsNeededTitle => 'Potrebna so dovoljenja'; + + @override + String get permissionsNeededOpenSettings => 'Odpri nastavitve'; + + @override + String get permissionsDeniedCameraAccess => + 'Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Označi kanal kot prebran'; + + @override + String get actionSheetOptionListOfTopics => 'Seznam tem'; + + @override + String get actionSheetOptionMuteTopic => 'Utišaj temo'; + + @override + String get actionSheetOptionUnmuteTopic => 'Prekliči utišanje teme'; + + @override + String get actionSheetOptionFollowTopic => 'Sledi temi'; + + @override + String get actionSheetOptionUnfollowTopic => 'Prenehaj slediti temi'; + + @override + String get actionSheetOptionResolveTopic => 'Označi kot razrešeno'; + + @override + String get actionSheetOptionUnresolveTopic => 'Označi kot nerazrešeno'; + + @override + String get errorResolveTopicFailedTitle => + 'Neuspela označitev teme kot razrešene'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Neuspela označitev teme kot nerazrešene'; + + @override + String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Kopiraj povezavo do sporočila'; + + @override + String get actionSheetOptionMarkAsUnread => + 'Od tu naprej označi kot neprebrano'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Znova skrij utišano sporočilo'; + + @override + String get actionSheetOptionShare => 'Deli'; + + @override + String get actionSheetOptionQuoteMessage => 'Citiraj sporočilo'; + + @override + String get actionSheetOptionStarMessage => 'Označi sporočilo z zvezdico'; + + @override + String get actionSheetOptionUnstarMessage => 'Odstrani zvezdico s sporočila'; + + @override + String get actionSheetOptionEditMessage => 'Uredi sporočilo'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Nekaj je šlo narobe'; + + @override + String get errorWebAuthOperationalError => + 'Prišlo je do nepričakovane napake.'; + + @override + String get errorAccountLoggedInTitle => 'Račun je že prijavljen'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Račun $email na $server je že na vašem seznamu računov.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Ni bilo mogoče pridobiti vira sporočila.'; + + @override + String get errorCopyingFailed => 'Kopiranje ni uspelo'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Nalaganje datoteke ni uspelo: $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 datotek presega', + few: '$num datoteke presegajo', + two: '$num datoteki presegata', + one: '$num datoteka presega', + ); + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'ne bodo naložene', + few: 'ne bodo naložene', + two: 'ne bosta naloženi', + one: 'ne bo naložena', + ); + return '$_temp0 omejitev velikosti strežnika ($maxFileUploadSizeMib MiB) in $_temp1:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num datotek je prevelikih', + few: '$num datoteke so prevelike', + two: '$num datoteki sta preveliki', + one: '$num datoteka je prevelika', + ); + return '\"$_temp0\"'; + } + + @override + String get errorLoginInvalidInputTitle => 'Neveljaven vnos'; + + @override + String get errorLoginFailedTitle => 'Prijava ni uspela'; + + @override + String get errorMessageNotSent => 'Pošiljanje sporočila ni uspelo'; + + @override + String get errorMessageEditNotSaved => 'Sporočilo ni bilo shranjeno'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Ni se mogoče povezati s strežnikom:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Povezave ni bilo mogoče vzpostaviti'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Zdi se, da to sporočilo ne obstaja.'; + + @override + String get errorQuotationFailed => 'Citiranje ni uspelo'; + + @override + String errorServerMessage(String message) { + return 'Strežnik je sporočil:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Napaka pri povezovanju z Zulipom. Poskušamo znova…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Napaka pri povezovanju z Zulipom na $serverUrl. Poskusili bomo znova:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Napaka pri obravnavi posodobitve. Povezujemo se znova…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Napaka pri obravnavi posodobitve iz strežnika $serverUrl; poskusili bomo znova.\n\nNapaka: $error\n\nDogodek: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Povezave ni mogoče odpreti'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Povezave ni bilo mogoče odpreti: $url'; + } + + @override + String get errorMuteTopicFailed => 'Utišanje teme ni uspelo'; + + @override + String get errorUnmuteTopicFailed => 'Preklic utišanja teme ni uspel'; + + @override + String get errorFollowTopicFailed => 'Sledenje temi ni uspelo'; + + @override + String get errorUnfollowTopicFailed => 'Prenehanje sledenja temi ni uspelo'; + + @override + String get errorSharingFailed => 'Deljenje ni uspelo'; + + @override + String get errorStarMessageFailedTitle => + 'Sporočila ni bilo mogoče označiti z zvezdico'; + + @override + String get errorUnstarMessageFailedTitle => + 'Sporočilu ni bilo mogoče odstraniti zvezdice'; + + @override + String get errorCouldNotEditMessageTitle => 'Sporočila ni mogoče urediti'; + + @override + String get successLinkCopied => 'Povezava je bila kopirana'; + + @override + String get successMessageTextCopied => 'Besedilo sporočila je bilo kopirano'; + + @override + String get successMessageLinkCopied => + 'Povezava do sporočila je bila kopirana'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Deaktiviranim uporabnikom ne morete pošiljati sporočil.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Nimate dovoljenja za objavljanje v tem kanalu.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Uredi sporočilo'; + + @override + String get composeBoxBannerButtonCancel => 'Prekliči'; + + @override + String get composeBoxBannerButtonSave => 'Shrani'; + + @override + String get editAlreadyInProgressTitle => 'Urejanje sporočila ni mogoče'; + + @override + String get editAlreadyInProgressMessage => + 'Urejanje je že v teku. Počakajte, da se konča.'; + + @override + String get savingMessageEditLabel => 'SHRANJEVANJE SPREMEMB…'; + + @override + String get savingMessageEditFailedLabel => 'UREJANJE NI SHRANJENO'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Želite zavreči sporočilo, ki ga pišete?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Zavrzi'; + + @override + String get composeBoxAttachFilesTooltip => 'Pripni datoteke'; + + @override + String get composeBoxAttachMediaTooltip => + 'Pripni fotografije ali videoposnetke'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Fotografiraj'; + + @override + String get composeBoxGenericContentHint => 'Vnesite sporočilo'; + + @override + String get newDmSheetComposeButtonLabel => 'Napiši'; + + @override + String get newDmSheetScreenTitle => 'Novo neposredno sporočilo'; + + @override + String get newDmFabButtonLabel => 'Novo neposredno sporočilo'; + + @override + String get newDmSheetSearchHintEmpty => 'Dodajte enega ali več uporabnikov'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Dodajte še enega uporabnika…'; + + @override + String get newDmSheetNoUsersFound => 'Ni zadetkov med uporabniki'; + + @override + String composeBoxDmContentHint(String user) { + return 'Sporočilo @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Skupinsko sporočilo'; + + @override + String get composeBoxSelfDmContentHint => 'Zapišite opombo zase'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Sporočilo $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Pripravljanje…'; + + @override + String get composeBoxSendTooltip => 'Pošlji'; + + @override + String get unknownChannelName => '(neznan kanal)'; + + @override + String get composeBoxTopicHintText => 'Tema'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Vnesite temo (ali pustite prazno za »$defaultTopicName«)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Nalaganje $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(nalaganje sporočila $messageId)'; + } + + @override + String get unknownUserName => '(neznan uporabnik)'; + + @override + String get dmsWithYourselfPageTitle => 'Neposredna sporočila s samim seboj'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Vi in $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'Neposredna sporočila z $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Sporočila sebi'; + + @override + String get contentValidationErrorTooLong => + 'Dolžina sporočila ne sme presegati 10000 znakov.'; + + @override + String get contentValidationErrorEmpty => 'Ni vsebine za pošiljanje!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Počakajte, da se citat zaključi.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Počakajte, da se nalaganje konča.'; + + @override + String get dialogCancel => 'Prekliči'; + + @override + String get dialogContinue => 'Nadaljuj'; + + @override + String get dialogClose => 'Zapri'; + + @override + String get errorDialogLearnMore => 'Več o tem'; + + @override + String get errorDialogContinue => 'V redu'; + + @override + String get errorDialogTitle => 'Napaka'; + + @override + String get snackBarDetails => 'Podrobnosti'; + + @override + String get lightboxCopyLinkTooltip => 'Kopiraj povezavo'; + + @override + String get lightboxVideoCurrentPosition => 'Trenutni položaj'; + + @override + String get lightboxVideoDuration => 'Trajanje videa'; + + @override + String get loginPageTitle => 'Prijava'; + + @override + String get loginFormSubmitLabel => 'Prijava'; + + @override + String get loginMethodDivider => 'ALI'; + + @override + String signInWithFoo(String method) { + return 'Prijava z $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Dodaj račun'; + + @override + String get loginServerUrlLabel => 'URL strežnika Zulip'; + + @override + String get loginHidePassword => 'Skrij geslo'; + + @override + String get loginEmailLabel => 'E-poštni naslov'; + + @override + String get loginErrorMissingEmail => 'Vnesite svoj e-poštni naslov.'; + + @override + String get loginPasswordLabel => 'Geslo'; + + @override + String get loginErrorMissingPassword => 'Vnesite svoje geslo.'; + + @override + String get loginUsernameLabel => 'Uporabniško ime'; + + @override + String get loginErrorMissingUsername => 'Vnesite svoje uporabniško ime.'; + + @override + String get topicValidationErrorTooLong => + 'Dolžina teme ne sme presegati 60 znakov.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Teme so v tej organizaciji obvezne.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url uporablja strežnik Zulip $zulipVersion, ki ni podprt. Najnižja podprta različica je strežnik Zulip $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Vašega računa na $url ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.'; + } + + @override + String get errorInvalidResponse => 'Strežnik je poslal neveljaven odgovor.'; + + @override + String get errorNetworkRequestFailed => 'Omrežna zahteva je spodletela'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Omrežna zahteva je spodletela: Stanje HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Videa ni mogoče predvajati.'; + + @override + String get serverUrlValidationErrorEmpty => 'Vnesite URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Vnesite veljaven URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Vnesite URL strežnika, ne vašega e-poštnega naslova.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'URL strežnika se mora začeti s http:// ali https://.'; + + @override + String get spoilerDefaultHeaderText => 'Skrito'; + + @override + String get markAllAsReadLabel => 'Označi vsa sporočila kot prebrana'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num sporočil', + few: '$num sporočila', + two: '$num sporočili', + one: '$num sporočilo', + ); + return 'Označeno je $_temp0 kot prebrano.'; + } + + @override + String get markAsReadInProgress => 'Označevanje sporočil kot prebranih…'; + + @override + String get errorMarkAsReadFailedTitle => 'Označevanje kot prebrano ni uspelo'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Označeno je $num sporočil kot neprebranih', + few: 'Označena so $num sporočila kot neprebrana', + two: 'Označeni sta $num sporočili kot neprebrani', + one: 'Označeno je $num sporočilo kot neprebrano', + ); + return '$_temp0.'; + } + + @override + String get markAsUnreadInProgress => 'Označevanje sporočil kot neprebranih…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Označevanje kot neprebrano ni uspelo'; + + @override + String get today => 'Danes'; + + @override + String get yesterday => 'Včeraj'; + + @override + String get userRoleOwner => 'Lastnik'; + + @override + String get userRoleAdministrator => 'Skrbnik'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Član'; + + @override + String get userRoleGuest => 'Gost'; + + @override + String get userRoleUnknown => 'Neznano'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Nabiralnik'; + + @override + String get inboxEmptyPlaceholder => + 'V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.'; + + @override + String get recentDmConversationsPageTitle => 'Neposredna sporočila'; + + @override + String get recentDmConversationsSectionHeader => 'Neposredna sporočila'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?'; + + @override + String get combinedFeedPageTitle => 'Združen prikaz'; + + @override + String get mentionsPageTitle => 'Omembe'; + + @override + String get starredMessagesPageTitle => 'Sporočila z zvezdico'; + + @override + String get channelsPageTitle => 'Kanali'; + + @override + String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.'; + + @override + String get mainMenuMyProfile => 'Moj profil'; + + @override + String get topicsButtonLabel => 'TEME'; + + @override + String get channelFeedButtonTooltip => 'Sporočila kanala'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers drugim osebam', + one: '1 drugi osebi', + ); + return '$senderFullName vam in $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pripeto'; + + @override + String get unpinnedSubscriptionsLabel => 'Nepripeto'; + + @override + String get notifSelfUser => 'Vi'; + + @override + String get reactedEmojiSelfUser => 'Vi'; + + @override + String onePersonTyping(String typist) { + return '$typist tipka…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist in $otherTypist tipkata…'; + } + + @override + String get manyPeopleTyping => 'Več oseb tipka…'; + + @override + String get wildcardMentionAll => 'vsi'; + + @override + String get wildcardMentionEveryone => 'vsi'; + + @override + String get wildcardMentionChannel => 'kanal'; + + @override + String get wildcardMentionStream => 'tok'; + + @override + String get wildcardMentionTopic => 'tema'; + + @override + String get wildcardMentionChannelDescription => 'Obvesti kanal'; + + @override + String get wildcardMentionStreamDescription => 'Obvesti tok'; + + @override + String get wildcardMentionAllDmDescription => 'Obvesti prejemnike'; + + @override + String get wildcardMentionTopicDescription => 'Obvesti udeležence teme'; + + @override + String get messageIsEditedLabel => 'UREJENO'; + + @override + String get messageIsMovedLabel => 'PREMAKNJENO'; + + @override + String get messageNotSentLabel => 'SPOROČILO NI POSLANO'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'TEMA'; + + @override + String get themeSettingDark => 'Temna'; + + @override + String get themeSettingLight => 'Svetla'; + + @override + String get themeSettingSystem => 'Sistemska'; + + @override + String get openLinksWithInAppBrowser => + 'Odpri povezave v brskalniku znotraj aplikacije'; + + @override + String get pollWidgetQuestionMissing => 'Brez vprašanja.'; + + @override + String get pollWidgetOptionsMissing => 'Ta anketa še nima odgovorov.'; + + @override + String get initialAnchorSettingTitle => 'Odpri tok sporočil pri'; + + @override + String get initialAnchorSettingDescription => + 'Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Prvo neprebrano sporočilo'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Prvo neprebrano v pogovorih, najnovejše drugje'; + + @override + String get initialAnchorSettingNewestAlways => 'Najnovejše sporočilo'; + + @override + String get markReadOnScrollSettingTitle => + 'Ob pomikanju označi sporočila kot prebrana'; + + @override + String get markReadOnScrollSettingDescription => + 'Naj se sporočila ob pomikanju samodejno označijo kot prebrana?'; + + @override + String get markReadOnScrollSettingAlways => 'Vedno'; + + @override + String get markReadOnScrollSettingNever => 'Nikoli'; + + @override + String get markReadOnScrollSettingConversations => + 'Samo v pogledih pogovorov'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije'; + + @override + String get experimentalFeatureSettingsWarning => + 'Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.'; + + @override + String get errorNotificationOpenTitle => 'Obvestila ni bilo mogoče odpreti'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Računa, povezanega s tem obvestilom, ni bilo mogoče najti.'; + + @override + String get errorReactionAddingFailedTitle => 'Reakcije ni bilo mogoče dodati'; + + @override + String get errorReactionRemovingFailedTitle => + 'Reakcije ni bilo mogoče odstraniti'; + + @override + String get emojiReactionsMore => 'več'; + + @override + String get emojiPickerSearchEmoji => 'Iskanje emojijev'; + + @override + String get noEarlierMessages => 'Ni starejših sporočil'; + + @override + String get revealButtonLabel => 'Prikaži sporočilo utišanega pošiljatelja'; + + @override + String get mutedUser => 'Uporabnik je utišan'; + + @override + String get scrollToBottomTooltip => 'Premakni se na konec'; + + @override + String get appVersionUnknownPlaceholder => '(...)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index c1898631fd..82e07f3634 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get aboutPageTapToView => 'Натисніть, щоб переглянути'; + @override + String get upgradeWelcomeDialogTitle => + 'Ласкаво просимо у новий додаток Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Ви знайдете звичні можливості у більш швидкому і легкому додатку.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Ознайомтесь з анонсом у блозі!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Ходімо!'; + @override String get chooseAccountPageTitle => 'Обрати обліковий запис'; @@ -79,6 +93,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Позначити канал як прочитаний'; + @override + String get actionSheetOptionListOfTopics => 'Список тем'; + @override String get actionSheetOptionMuteTopic => 'Заглушити тему'; @@ -115,18 +132,25 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; + @override + String get actionSheetOptionHideMutedMessage => + 'Сховати заглушене повідомлення'; + @override String get actionSheetOptionShare => 'Поширити'; @override - String get actionSheetOptionQuoteAndReply => 'Цитата і відповідь'; + String get actionSheetOptionQuoteMessage => 'Цитувати повідомлення'; @override - String get actionSheetOptionStarMessage => 'Позначити повідомлення зірочкою'; + String get actionSheetOptionStarMessage => 'Вибрати повідомлення'; @override String get actionSheetOptionUnstarMessage => - 'Зняти позначку зірочки з повідомлення'; + 'Зняти позначку зірки з повідомлення'; + + @override + String get actionSheetOptionEditMessage => 'Редагувати повідомлення'; @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; @@ -147,7 +171,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не вдалося отримати джерело повідомлення'; + 'Не вдалося отримати джерело повідомлення.'; @override String get errorCopyingFailed => 'Помилка копіювання'; @@ -197,6 +221,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Повідомлення не надіслано'; + @override + String get errorMessageEditNotSaved => 'Повідомлення не збережено'; + @override String errorLoginCouldNotConnect(String url) { return 'Не вдалося підключитися до сервера:\n$url'; @@ -264,11 +291,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorStarMessageFailedTitle => - 'Не вдалося позначити повідомлення зірочкою'; + 'Не вдалося позначити повідомлення зіркою'; @override String get errorUnstarMessageFailedTitle => - 'Не вдалося зняти позначку зірочки з повідомлення'; + 'Не вдалося зняти позначку зірки з повідомлення'; + + @override + String get errorCouldNotEditMessageTitle => + 'Не вдалося редагувати повідомлення'; @override String get successLinkCopied => 'Посилання скопійовано'; @@ -288,6 +319,43 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Ви не маєте дозволу на публікацію в цьому каналі.'; + @override + String get composeBoxBannerLabelEditMessage => 'Редагування повідомлення'; + + @override + String get composeBoxBannerButtonCancel => 'Відміна'; + + @override + String get composeBoxBannerButtonSave => 'Зберегти'; + + @override + String get editAlreadyInProgressTitle => 'Неможливо редагувати повідомлення'; + + @override + String get editAlreadyInProgressMessage => + 'Редагування уже виконується. Дочекайтеся його завершення.'; + + @override + String get savingMessageEditLabel => 'ЗБЕРЕЖЕННЯ ПРАВОК…'; + + @override + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ ЗБЕРЕЖЕНІ'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Відмовитися від написаного повідомлення?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'При редагуванні повідомлення, текст з поля для редагування видаляється.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'При відновленні невідправленого повідомлення, вміст поля редагування очищається.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Скинути'; + @override String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; @@ -300,13 +368,31 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести повідомлення'; + @override + String get newDmSheetComposeButtonLabel => 'Написати'; + + @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'; } @override - String get composeBoxGroupDmContentHint => 'Група повідомлень'; + String get composeBoxGroupDmContentHint => 'Написати групі'; @override String get composeBoxSelfDmContentHint => 'Занотувати щось'; @@ -316,6 +402,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Надіслати повідомлення $destination'; } + @override + String get preparingEditMessageContentInput => 'Підготовка…'; + @override String get composeBoxSendTooltip => 'Надіслати'; @@ -325,6 +414,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Вкажіть тему (або залиште “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Завантаження $filename…'; @@ -351,6 +445,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Особисті повідомлення з $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + @override String get messageListGroupYouWithYourself => 'Повідомлення з собою'; @@ -464,7 +564,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь'; + String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь.'; @override String get errorNetworkRequestFailed => 'Помилка запиту мережі'; @@ -485,7 +585,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Неможливо відтворити відео'; + String get errorVideoPlayerFailed => 'Неможливо відтворити відео.'; @override String get serverUrlValidationErrorEmpty => 'Будь ласка, введіть URL.'; @@ -567,9 +667,22 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get userRoleUnknown => 'Невідомо'; + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Вхідні'; + @override + String get inboxEmptyPlaceholder => + 'Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.'; + @override String get recentDmConversationsPageTitle => 'Особисті повідомлення'; @@ -577,20 +690,30 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get recentDmConversationsSectionHeader => 'Особисті повідомлення'; @override - String get combinedFeedPageTitle => 'Комбінована стрічка'; + String get recentDmConversationsEmptyPlaceholder => + 'У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?'; + + @override + String get combinedFeedPageTitle => 'Об\'єднана стрічка'; @override String get mentionsPageTitle => 'Згадки'; @override - String get starredMessagesPageTitle => 'Повідомлення, позначені зірочкою'; + String get starredMessagesPageTitle => 'Вибрані повідомлення'; @override String get channelsPageTitle => 'Канали'; + @override + String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; + @override String get mainMenuMyProfile => 'Мій профіль'; + @override + String get topicsButtonLabel => 'ТЕМИ'; + @override String get channelFeedButtonTooltip => 'Стрічка каналу'; @@ -611,9 +734,6 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Відкріплені'; - @override - String get subscriptionListNoChannels => 'Канали не знайдено'; - @override String get notifSelfUser => 'Ви'; @@ -666,6 +786,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; + @override + String get messageNotSentLabel => 'ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -694,6 +817,46 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'У цьому опитуванні ще немає варіантів.'; + @override + String get initialAnchorSettingTitle => 'Де відкривати стрічку повідомлень'; + + @override + String get initialAnchorSettingDescription => + 'Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Перше непрочитане повідомлення'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях'; + + @override + String get initialAnchorSettingNewestAlways => 'Найновіше повідомлення'; + + @override + String get markReadOnScrollSettingTitle => + 'Відмічати повідомлення як прочитані при прокручуванні'; + + @override + String get markReadOnScrollSettingDescription => + 'При прокручуванні повідомлень автоматично відмічати їх як прочитані?'; + + @override + String get markReadOnScrollSettingAlways => 'Завжди'; + + @override + String get markReadOnScrollSettingNever => 'Ніколи'; + + @override + String get markReadOnScrollSettingConversations => + 'Тільки при перегляді бесід'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.'; + @override String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції'; @@ -705,8 +868,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; @override - String get errorNotificationOpenAccountMissing => - 'Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.'; + String get errorNotificationOpenAccountNotFound => + 'Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; @@ -723,6 +886,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; + @override + String get revealButtonLabel => + 'Показати повідомлення заглушеного відправника'; + + @override + String get mutedUser => 'Заглушений користувач'; + @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..a5ab16e499 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -0,0 +1,2142 @@ +// 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 upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + + @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 actionSheetOptionQuoteMessage => 'Quote message'; + + @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 discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, 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 newDmSheetComposeButtonLabel => 'Compose'; + + @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 emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @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 searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + + @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 channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @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 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 initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @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 revealButtonLabel => 'Reveal message'; + + @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 aboutPageTitle => '关于 Zulip'; + + @override + String get aboutPageAppVersion => '应用程序版本'; + + @override + String get aboutPageOpenSourceLicenses => '开源许可'; + + @override + String get aboutPageTapToView => '查看更多'; + + @override + String get upgradeWelcomeDialogTitle => '欢迎来到新的 Zulip 应用!'; + + @override + String get upgradeWelcomeDialogMessage => '您将会得到到更快,更流畅的体验。'; + + @override + String get upgradeWelcomeDialogLinkText => '来看看最新的公告吧!'; + + @override + String get upgradeWelcomeDialogDismiss => '开始吧'; + + @override + String get chooseAccountPageTitle => '选择账号'; + + @override + String get settingsPageTitle => '设置'; + + @override + String get switchAccountButton => '切换账号'; + + @override + String tryAnotherAccountMessage(Object url) { + return '您在 $url 的账号加载时间过长。'; + } + + @override + String get tryAnotherAccountButton => '尝试另一个账号'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogMessage => '下次登入此账号时,您将需要重新输入组织网址和账号信息。'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '添加一个账号'; + + @override + String get profileButtonSendDirectMessage => '发送私信'; + + @override + String get errorCouldNotShowUserProfile => '无法显示用户个人资料。'; + + @override + String get permissionsNeededTitle => '需要额外权限'; + + @override + String get permissionsNeededOpenSettings => '打开设置'; + + @override + String get permissionsDeniedCameraAccess => '上传图片前,请在设置授予 Zulip 相应的权限。'; + + @override + String get permissionsDeniedReadExternalStorage => + '上传文件前,请在设置授予 Zulip 相应的权限。'; + + @override + String get actionSheetOptionMarkChannelAsRead => '标记频道为已读'; + + @override + String get actionSheetOptionListOfTopics => '话题列表'; + + @override + String get actionSheetOptionMuteTopic => '静音话题'; + + @override + String get actionSheetOptionUnmuteTopic => '取消静音话题'; + + @override + String get actionSheetOptionFollowTopic => '关注话题'; + + @override + String get actionSheetOptionUnfollowTopic => '取消关注话题'; + + @override + String get actionSheetOptionResolveTopic => '标记为已解决'; + + @override + String get actionSheetOptionUnresolveTopic => '标记为未解决'; + + @override + String get errorResolveTopicFailedTitle => '未能将话题标记为解决'; + + @override + String get errorUnresolveTopicFailedTitle => '未能将话题标记为未解决'; + + @override + String get actionSheetOptionCopyMessageText => '复制消息文本'; + + @override + String get actionSheetOptionCopyMessageLink => '复制消息链接'; + + @override + String get actionSheetOptionMarkAsUnread => '从这里标为未读'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隐藏静音消息'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteMessage => '引用消息'; + + @override + String get actionSheetOptionStarMessage => '添加星标消息标记'; + + @override + String get actionSheetOptionUnstarMessage => '取消星标消息标记'; + + @override + String get actionSheetOptionEditMessage => '编辑消息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '将话题标为已读'; + + @override + String get errorWebAuthOperationalErrorTitle => '出现了一些问题'; + + @override + String get errorWebAuthOperationalError => '发生了未知的错误。'; + + @override + String get errorAccountLoggedInTitle => '已经登入该账号'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的账号 $email 已经在您的账号列表了。'; + } + + @override + String get errorCouldNotFetchMessageSource => '未能获取原始消息。'; + + @override + String get errorCopyingFailed => '未能复制消息文本'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return '未能上传文件:$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 个您上传的文件', + ); + return '$_temp0大小超过了该组织 $maxFileUploadSizeMib MiB 的限制:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + return '文件过大'; + } + + @override + String get errorLoginInvalidInputTitle => '输入的信息不正确'; + + @override + String get errorLoginFailedTitle => '未能登入'; + + @override + String get errorMessageNotSent => '未能发送消息'; + + @override + String get errorMessageEditNotSaved => '未能保存消息编辑'; + + @override + String errorLoginCouldNotConnect(String url) { + return '未能连接到服务器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '未能连接'; + + @override + String get errorMessageDoesNotSeemToExist => '找不到此消息。'; + + @override + String get errorQuotationFailed => '未能引用消息'; + + @override + String errorServerMessage(String message) { + return '服务器:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => '未能连接到 Zulip. 重试中…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return '未能连接到在 $serverUrl 的 Zulip 服务器。即将重连:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => '处理 Zulip 事件时发生了一些问题。即将重连…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return '处理来自 $serverUrl 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:$error\n\n事件:$event'; + } + + @override + String get errorCouldNotOpenLinkTitle => '未能打开链接'; + + @override + String errorCouldNotOpenLink(String url) { + return '未能打开此链接:$url'; + } + + @override + String get errorMuteTopicFailed => '未能静音话题'; + + @override + String get errorUnmuteTopicFailed => '未能取消静音话题'; + + @override + String get errorFollowTopicFailed => '未能关注话题'; + + @override + String get errorUnfollowTopicFailed => '未能取消关注话题'; + + @override + String get errorSharingFailed => '分享失败'; + + @override + String get errorStarMessageFailedTitle => '未能添加星标消息标记'; + + @override + String get errorUnstarMessageFailedTitle => '未能取消星标消息标记'; + + @override + String get errorCouldNotEditMessageTitle => '未能编辑消息'; + + @override + String get successLinkCopied => '已复制链接'; + + @override + String get successMessageTextCopied => '已复制消息文本'; + + @override + String get successMessageLinkCopied => '已复制消息链接'; + + @override + String get errorBannerDeactivatedDmLabel => '您不能向被停用的用户发送消息。'; + + @override + String get errorBannerCannotPostInChannelLabel => '您没有足够的权限在此频道发送消息。'; + + @override + String get composeBoxBannerLabelEditMessage => '编辑消息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get composeBoxBannerButtonSave => '保存'; + + @override + String get editAlreadyInProgressTitle => '未能编辑消息'; + + @override + String get editAlreadyInProgressMessage => '已有正在被编辑的消息。请在其完成后重试。'; + + @override + String get savingMessageEditLabel => '保存中…'; + + @override + String get savingMessageEditFailedLabel => '编辑失败'; + + @override + String get discardDraftConfirmationDialogTitle => '放弃您正在撰写的消息?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + '当您编辑消息时,文本框中已有的内容将会被清空。'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + '当您恢复未能发送的消息时,文本框已有的内容将会被清空。'; + + @override + String get discardDraftConfirmationDialogConfirmButton => '清空'; + + @override + String get composeBoxAttachFilesTooltip => '上传文件'; + + @override + String get composeBoxAttachMediaTooltip => '上传图片或视频'; + + @override + String get composeBoxAttachFromCameraTooltip => '拍摄照片'; + + @override + String get composeBoxGenericContentHint => '撰写消息'; + + @override + String get newDmSheetComposeButtonLabel => '撰写消息'; + + @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'; + } + + @override + String get composeBoxGroupDmContentHint => '发送私信到群组'; + + @override + String get composeBoxSelfDmContentHint => '向自己撰写消息'; + + @override + String composeBoxChannelContentHint(String destination) { + return '发送消息到 $destination'; + } + + @override + String get preparingEditMessageContentInput => '准备编辑消息…'; + + @override + String get composeBoxSendTooltip => '发送'; + + @override + String get unknownChannelName => '(未知频道)'; + + @override + String get composeBoxTopicHintText => '话题'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return '输入话题(默认为“$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上传 $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(加载消息 $messageId)'; + } + + @override + String get unknownUserName => '(未知用户)'; + + @override + String get dmsWithYourselfPageTitle => '与自己的私信'; + + @override + String messageListGroupYouAndOthers(String others) { + return '您和$others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return '与$others的私信'; + } + + @override + String get messageListGroupYouWithYourself => '与自己的私信'; + + @override + String get contentValidationErrorTooLong => '消息的长度不能超过10000个字符。'; + + @override + String get contentValidationErrorEmpty => '发送的消息不能为空!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '请等待引用消息完成。'; + + @override + String get contentValidationErrorUploadInProgress => '请等待上传完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '继续'; + + @override + String get dialogClose => '关闭'; + + @override + String get errorDialogLearnMore => '更多信息'; + + @override + String get errorDialogContinue => '好的'; + + @override + String get errorDialogTitle => '错误'; + + @override + String get snackBarDetails => '详情'; + + @override + String get lightboxCopyLinkTooltip => '复制链接'; + + @override + String get lightboxVideoCurrentPosition => '当前进度'; + + @override + String get lightboxVideoDuration => '视频时长'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String get loginMethodDivider => '或'; + + @override + String signInWithFoo(String method) { + return '使用$method登入'; + } + + @override + String get loginAddAnAccountPageTitle => '添加账号'; + + @override + String get loginServerUrlLabel => 'Zulip 服务器网址'; + + @override + String get loginHidePassword => '隐藏密码'; + + @override + String get loginEmailLabel => '电子邮箱地址'; + + @override + String get loginErrorMissingEmail => '请输入电子邮箱地址。'; + + @override + String get loginPasswordLabel => '密码'; + + @override + String get loginErrorMissingPassword => '请输入密码。'; + + @override + String get loginUsernameLabel => '用户名'; + + @override + String get loginErrorMissingUsername => '请输入用户名。'; + + @override + String get topicValidationErrorTooLong => '话题长度不应该超过 60 个字符。'; + + @override + String get topicValidationErrorMandatoryButEmpty => '话题在该组织为必填项。'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url 运行的 Zulip 服务器版本 $zulipVersion 过低。该客户端只支持 $minSupportedZulipVersion 及以后的服务器版本。'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return '您在 $url 的账号无法被登入。请重试或者使用另外的账号。'; + } + + @override + String get errorInvalidResponse => '服务器的回复不合法。'; + + @override + String get errorNetworkRequestFailed => '网络请求失败'; + + @override + String errorMalformedResponse(int httpStatus) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return '网络请求失败;HTTP 状态码 $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => '未能播放视频。'; + + @override + String get serverUrlValidationErrorEmpty => '请输入网址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '请输入正确的网址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '请输入服务器网址,而不是您的电子邮件。'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + '服务器网址必须以 http:// 或 https:// 开头。'; + + @override + String get spoilerDefaultHeaderText => '剧透'; + + @override + String get markAllAsReadLabel => '将所有消息标为已读'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为已读。'; + } + + @override + String get markAsReadInProgress => '正在将消息标为已读…'; + + @override + String get errorMarkAsReadFailedTitle => '未能将消息标为已读'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为未读。'; + } + + @override + String get markAsUnreadInProgress => '正在将消息标为未读…'; + + @override + String get errorMarkAsUnreadFailedTitle => '未能将消息标为未读'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userRoleOwner => '所有者'; + + @override + String get userRoleAdministrator => '管理员'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成员'; + + @override + String get userRoleGuest => '访客'; + + @override + String get userRoleUnknown => '未知'; + + @override + String get inboxPageTitle => '收件箱'; + + @override + String get inboxEmptyPlaceholder => '您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。'; + + @override + String get recentDmConversationsPageTitle => '私信'; + + @override + String get recentDmConversationsSectionHeader => '私信'; + + @override + String get recentDmConversationsEmptyPlaceholder => '您还没有任何私信消息!何不开启一个新对话?'; + + @override + String get combinedFeedPageTitle => '综合消息'; + + @override + String get mentionsPageTitle => '被提及消息'; + + @override + String get starredMessagesPageTitle => '星标消息'; + + @override + String get channelsPageTitle => '频道'; + + @override + String get channelsEmptyPlaceholder => '您还没有订阅任何频道。'; + + @override + String get mainMenuMyProfile => '个人资料'; + + @override + String get topicsButtonLabel => '话题'; + + @override + String get channelFeedButtonTooltip => '频道订阅'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 个用户', + ); + return '$senderFullName向您和其他 $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => '置顶'; + + @override + String get unpinnedSubscriptionsLabel => '未置顶'; + + @override + String get notifSelfUser => '您'; + + @override + String get reactedEmojiSelfUser => '您'; + + @override + String onePersonTyping(String typist) { + return '$typist正在输入…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist和$otherTypist正在输入…'; + } + + @override + String get manyPeopleTyping => '多个用户正在输入…'; + + @override + String get wildcardMentionAll => '所有人'; + + @override + String get wildcardMentionEveryone => '所有人'; + + @override + String get wildcardMentionChannel => '频道'; + + @override + String get wildcardMentionStream => '频道'; + + @override + String get wildcardMentionTopic => '话题'; + + @override + String get wildcardMentionChannelDescription => '通知频道'; + + @override + String get wildcardMentionStreamDescription => '通知频道'; + + @override + String get wildcardMentionAllDmDescription => '通知收件人'; + + @override + String get wildcardMentionTopicDescription => '通知话题'; + + @override + String get messageIsEditedLabel => '已编辑'; + + @override + String get messageIsMovedLabel => '已移动'; + + @override + String get messageNotSentLabel => '消息未发送'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => '主题'; + + @override + String get themeSettingDark => '暗色'; + + @override + String get themeSettingLight => '浅色'; + + @override + String get themeSettingSystem => '系统'; + + @override + String get openLinksWithInAppBrowser => '使用内置浏览器打开链接'; + + @override + String get pollWidgetQuestionMissing => '无问题。'; + + @override + String get pollWidgetOptionsMissing => '该投票还没有任何选项。'; + + @override + String get initialAnchorSettingTitle => '设置消息起始位置于'; + + @override + String get initialAnchorSettingDescription => '您可以将消息的起始位置设置为第一条未读消息或者最新消息。'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一条未读消息'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + '在单个话题或私信的第一条未读消息;在其他情况下的最新消息'; + + @override + String get initialAnchorSettingNewestAlways => '最新消息'; + + @override + String get markReadOnScrollSettingTitle => '滑动时将消息标为已读'; + + @override + String get markReadOnScrollSettingDescription => '在滑动浏览消息时,是否自动将它们标记为已读?'; + + @override + String get markReadOnScrollSettingAlways => '总是'; + + @override + String get markReadOnScrollSettingNever => '从不'; + + @override + String get markReadOnScrollSettingConversations => '只在对话视图'; + + @override + String get markReadOnScrollSettingConversationsDescription => + '只将在同一个话题或私聊中的消息自动标记为已读。'; + + @override + String get experimentalFeatureSettingsPageTitle => '实验功能'; + + @override + String get experimentalFeatureSettingsWarning => + '以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。'; + + @override + String get errorNotificationOpenTitle => '未能打开消息提醒'; + + @override + String get errorNotificationOpenAccountNotFound => '未能找到关联该消息提醒的账号。'; + + @override + String get errorReactionAddingFailedTitle => '未能添加表情符号'; + + @override + String get errorReactionRemovingFailedTitle => '未能移除表情符号'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜索表情符号'; + + @override + String get noEarlierMessages => '没有更早的消息了'; + + @override + String get revealButtonLabel => '显示静音用户发送的消息'; + + @override + String get mutedUser => '静音用户'; + + @override + String get scrollToBottomTooltip => '拖动到最底'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// 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 aboutPageTitle => '關於 Zulip'; + + @override + String get aboutPageAppVersion => 'App 版本'; + + @override + String get aboutPageOpenSourceLicenses => '開源授權條款'; + + @override + String get aboutPageTapToView => '點選查看'; + + @override + String get upgradeWelcomeDialogTitle => '歡迎使用新 Zulip 應用程式!'; + + @override + String get chooseAccountPageTitle => '選取帳號'; + + @override + String get settingsPageTitle => '設定'; + + @override + String get switchAccountButton => '切換帳號'; + + @override + String tryAnotherAccountMessage(Object url) { + return '你在 $url 的帳號載入的比較久'; + } + + @override + String get tryAnotherAccountButton => '請嘗試別的帳號'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '增添帳號'; + + @override + String get profileButtonSendDirectMessage => '發送私訊'; + + @override + String get permissionsNeededTitle => '需要的權限'; + + @override + String get permissionsNeededOpenSettings => '開啟設定'; + + @override + String get actionSheetOptionMarkChannelAsRead => '標註頻道為已讀'; + + @override + String get actionSheetOptionListOfTopics => '話題列表'; + + @override + String get actionSheetOptionMuteTopic => '靜音話題'; + + @override + String get actionSheetOptionUnmuteTopic => '取消靜音話題'; + + @override + String get actionSheetOptionFollowTopic => '跟隨話題'; + + @override + String get actionSheetOptionUnfollowTopic => '取消跟隨話題'; + + @override + String get actionSheetOptionResolveTopic => '標註為已解決'; + + @override + String get actionSheetOptionUnresolveTopic => '標註為未解決'; + + @override + String get errorResolveTopicFailedTitle => '無法標註話題為已解決'; + + @override + String get errorUnresolveTopicFailedTitle => '無法標註話題為未解決'; + + @override + String get actionSheetOptionCopyMessageText => '複製訊息文字'; + + @override + String get actionSheetOptionCopyMessageLink => '複製訊息連結'; + + @override + String get actionSheetOptionMarkAsUnread => '從這裡開始標註為未讀'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隱藏已靜音的話題'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteMessage => '引述訊息'; + + @override + String get actionSheetOptionStarMessage => '收藏訊息'; + + @override + String get actionSheetOptionUnstarMessage => '取消收藏訊息'; + + @override + String get actionSheetOptionEditMessage => '編輯訊息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '標註話題為已讀'; + + @override + String get errorWebAuthOperationalErrorTitle => '出錯了'; + + @override + String get errorWebAuthOperationalError => '出現了意外的錯誤。'; + + @override + String get errorAccountLoggedInTitle => '帳號已經登入了'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的帳號 $email 已經存在帳號清單中。'; + } + + @override + String get errorCopyingFailed => '複製失敗'; + + @override + String get errorLoginFailedTitle => '登入失敗'; + + @override + String errorLoginCouldNotConnect(String url) { + return '無法連線到伺服器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '無法連線'; + + @override + String get errorMessageDoesNotSeemToExist => '該訊息似乎不存在。'; + + @override + String get errorQuotationFailed => '引述失敗'; + + @override + String get errorCouldNotOpenLinkTitle => '無法開啟連結'; + + @override + String errorCouldNotOpenLink(String url) { + return '無法開啟連結: $url'; + } + + @override + String get errorMuteTopicFailed => '無法靜音話題'; + + @override + String get errorUnmuteTopicFailed => '無法取消靜音話題'; + + @override + String get errorFollowTopicFailed => '無法跟隨話題'; + + @override + String get errorUnfollowTopicFailed => '無法取消跟隨話題'; + + @override + String get errorSharingFailed => '分享失敗。'; + + @override + String get errorStarMessageFailedTitle => '無法收藏訊息'; + + @override + String get errorUnstarMessageFailedTitle => '無法取消收藏訊息'; + + @override + String get errorCouldNotEditMessageTitle => '無法編輯訊息'; + + @override + String get successLinkCopied => '已複製連結'; + + @override + String get successMessageTextCopied => '已複製訊息文字'; + + @override + String get successMessageLinkCopied => '已複製訊息連結'; + + @override + String get composeBoxBannerLabelEditMessage => '編輯訊息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get editAlreadyInProgressTitle => '無法編輯訊息'; + + @override + String get composeBoxAttachFilesTooltip => '附加檔案'; + + @override + String get composeBoxAttachMediaTooltip => '附加圖片或影片'; + + @override + String get newDmSheetScreenTitle => '新增私訊'; + + @override + String get newDmFabButtonLabel => '新增私訊'; + + @override + String get newDmSheetSearchHintEmpty => '增添一個或多個使用者'; + + @override + String get newDmSheetSearchHintSomeSelected => '增添其他使用者…'; + + @override + String composeBoxDmContentHint(String user) { + return '訊息 @$user'; + } + + @override + String get composeBoxGroupDmContentHint => '訊息群組'; + + @override + String composeBoxChannelContentHint(String destination) { + return '訊息 $destination'; + } + + @override + String get composeBoxTopicHintText => '話題'; + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上傳 $filename…'; + } + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '請等待引述完成。'; + + @override + String get contentValidationErrorUploadInProgress => '請等待上傳完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '繼續'; + + @override + String get dialogClose => '關閉'; + + @override + String get errorDialogLearnMore => '了解更多'; + + @override + String get errorDialogTitle => '錯誤'; + + @override + String get lightboxCopyLinkTooltip => '複製連結'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String signInWithFoo(String method) { + return '使用 $method 登入'; + } + + @override + String get loginAddAnAccountPageTitle => '增添帳號'; + + @override + String get loginServerUrlLabel => '您的 Zulip 伺服器網址'; + + @override + String get loginHidePassword => '隱藏密碼'; + + @override + String get loginEmailLabel => '電子郵件地址'; + + @override + String get loginErrorMissingEmail => '請輸入您的電子郵件地址。'; + + @override + String get loginPasswordLabel => '密碼'; + + @override + String get loginErrorMissingPassword => '請輸入您的密碼。'; + + @override + String get loginUsernameLabel => '使用者名稱'; + + @override + String get loginErrorMissingUsername => '請輸入您的使用者名稱。'; + + @override + String get errorInvalidResponse => '伺服器傳送了無效的請求。'; + + @override + String get errorNetworkRequestFailed => '網路請求失敗'; + + @override + String get errorVideoPlayerFailed => '無法播放影片。'; + + @override + String get serverUrlValidationErrorEmpty => '請輸入網址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '請輸入有效的網址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '請輸入伺服器網址,而非您的電子郵件。'; + + @override + String get spoilerDefaultHeaderText => '劇透'; + + @override + String get markAllAsReadLabel => '標註所有訊息為已讀'; + + @override + String get markAsUnreadInProgress => '正在標註訊息為未讀…'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userRoleOwner => '擁有者'; + + @override + String get userRoleAdministrator => '管理員'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成員'; + + @override + String get userRoleGuest => '訪客'; + + @override + String get inboxPageTitle => '收件匣'; + + @override + String get recentDmConversationsPageTitle => '私人訊息'; + + @override + String get recentDmConversationsSectionHeader => '私人訊息'; + + @override + String get combinedFeedPageTitle => '綜合饋給'; + + @override + String get mentionsPageTitle => '提及'; + + @override + String get channelsPageTitle => '頻道'; + + @override + String get topicsButtonLabel => '話題'; + + @override + String get channelFeedButtonTooltip => '頻道饋給'; + + @override + String get pinnedSubscriptionsLabel => '已釘選'; + + @override + String get unpinnedSubscriptionsLabel => '未釘選'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => '通知頻道'; + + @override + String get wildcardMentionTopicDescription => '通知話題'; + + @override + String get themeSettingTitle => '主題'; + + @override + String get themeSettingDark => '深色主題'; + + @override + String get themeSettingLight => '淺色主題'; + + @override + String get themeSettingSystem => '系統主題'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一則未讀訊息'; + + @override + String get initialAnchorSettingNewestAlways => '最新訊息'; + + @override + String get experimentalFeatureSettingsPageTitle => '實驗性功能'; + + @override + String get errorNotificationOpenTitle => '無法開啟通知'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜尋表情符號'; + + @override + String get mutedUser => '已靜音的使用者'; +} diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index 5f46d154e9..de56806a4b 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// Autogenerated from Pigeon (v25.3.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart new file mode 100644 index 0000000000..6c3e593e2c --- /dev/null +++ b/lib/host/notifications.dart @@ -0,0 +1 @@ +export './notifications.g.dart'; diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart new file mode 100644 index 0000000000..ce1a74d446 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,210 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class NotificationDataFromLaunch { + NotificationDataFromLaunch({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationDataFromLaunch decode(Object result) { + result as List; + return NotificationDataFromLaunch( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationDataFromLaunch || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class NotificationTapEvent { + NotificationTapEvent({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationTapEvent decode(Object result) { + result as List; + return NotificationTapEvent( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationTapEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationDataFromLaunch) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NotificationTapEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + case 130: + return NotificationTapEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +class NotificationHostApi { + /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + Future getNotificationDataFromLaunch() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as NotificationDataFromLaunch?); + } + } +} + +Stream notificationTapEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel notificationTapEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec); + return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as NotificationTapEvent; + }); +} + diff --git a/lib/log.dart b/lib/log.dart index c85d228263..64cb409a0e 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -80,6 +80,7 @@ typedef ReportErrorCallback = void Function( /// /// If `details` is non-null, the [SnackBar] will contain a button that would /// open a dialog containing the error details. +/// Prose in `details` should have final punctuation. // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. @@ -91,6 +92,8 @@ ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUs /// as the body. If called before the app's widget tree is ready /// (see [ZulipApp.ready]), then we give up on showing the message to the user, /// and just log the message to the console. +/// +/// Prose in `message` should have final punctuation. // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 073255bc9d..9b2efa2a6f 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -486,6 +486,7 @@ class MentionAutocompleteView extends AutocompleteView _compareByRelevance(userA, userB, @@ -556,7 +557,6 @@ class MentionAutocompleteView extends AutocompleteView _fetch() async { assert(!_isFetching); _isFetching = true; - final result = await getStreamTopics(store.connection, streamId: streamId); + final result = await getStreamTopics(store.connection, streamId: streamId, + allowEmptyTopicName: true, + ); _topics = result.topics.map((e) => e.name); _isFetching = false; return _startSearch(); @@ -942,13 +944,11 @@ class TopicAutocompleteQuery extends AutocompleteQuery { bool testTopic(TopicName topic, PerAccountStore store) { // TODO(#881): Sort by match relevance, like web does. - // ignore: unnecessary_null_comparison // null topic names soon to be enabled if (topic.displayName == null) { return store.realmEmptyTopicDisplayName.toLowerCase() .contains(raw.toLowerCase()); } return topic.displayName != raw - // ignore: unnecessary_non_null_assertion // null topic names soon to be enabled && topic.displayName!.toLowerCase().contains(raw.toLowerCase()); } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 198e0ae119..4d1a0adaac 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; import '../host/android_notifications.dart'; +import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; import '../widgets/store.dart'; import 'store.dart'; @@ -120,6 +121,11 @@ abstract class ZulipBinding { /// This wraps [url_launcher.closeInAppWebView]. Future closeInAppWebView(); + /// Provides access to the current UTC date and time. + /// + /// Outside tests, this just calls [DateTime.timestamp]. + DateTime utcNow(); + /// Provides access to a new stopwatch. /// /// Outside tests, this just calls the [Stopwatch] constructor. @@ -175,6 +181,9 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [notif_pigeon.NotificationHostApi] class. + NotificationPigeonApi get notificationPigeonApi; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -319,6 +328,19 @@ class PackageInfo { }); } +// Pigeon generates methods under `@EventChannelApi` annotated classes +// in global scope of the generated file. This is a helper class to +// namespace the notification related Pigeon API under a single class. +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); + + Stream notificationTapEventsStream() => + notif_pigeon.notificationTapEvents(); +} + /// A concrete binding for use in the live application. /// /// The global store returned by [getGlobalStore], and consequently by @@ -383,6 +405,9 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.closeInAppWebView(); } + @override + DateTime utcNow() => DateTime.timestamp(); + @override Stopwatch stopwatch() => Stopwatch(); @@ -461,6 +486,9 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 7c36dbb629..848e6f986b 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -322,7 +322,7 @@ class ChannelStoreImpl with ChannelStore { case SubscriptionProperty.color: subscription.color = event.value as int; case SubscriptionProperty.isMuted: - // TODO(#421) update [MessageListView] if affected + // TODO(#1255) update [MessageListView] if affected subscription.isMuted = event.value as bool; case SubscriptionProperty.inHomeView: subscription.isMuted = !(event.value as bool); @@ -354,7 +354,6 @@ class ChannelStoreImpl with ChannelStore { if (_warnInvalidVisibilityPolicy(visibilityPolicy)) { visibilityPolicy = UserTopicVisibilityPolicy.none; } - // TODO(#421) update [MessageListView] if affected if (visibilityPolicy == UserTopicVisibilityPolicy.none) { // This is the "zero value" for this type, which our data structure // represents by leaving the topic out entirely. diff --git a/lib/model/compose.dart b/lib/model/compose.dart index 2aa2ed9f44..f1145e555f 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -130,14 +130,33 @@ String wrapWithBacktickFence({required String content, String? infoString}) { /// To omit the user ID part ("|13313") whenever the name part is unambiguous, /// pass the full UserStore. This means accepting a linear scan /// through all users; avoid it in performance-sensitive codepaths. +/// +/// See also [userMentionFromMessage]. String userMention(User user, {bool silent = false, UserStore? users}) { bool includeUserId = users == null || users.allUsers.where((u) => u.fullName == user.fullName) .take(2).length == 2; - - return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; + return _userMentionImpl( + silent: silent, + fullName: user.fullName, + userId: includeUserId ? user.userId : null); } +/// An @-mention of an individual user, like @**Chris Bobbe|13313**, +/// from sender data in a [Message]. +/// +/// The user ID part ("|13313") is always included. +/// +/// See also [userMention]. +String userMentionFromMessage(Message message, {bool silent = false, required UserStore users}) => + _userMentionImpl( + silent: silent, + fullName: users.senderDisplayName(message, replaceIfMuted: false), + userId: message.senderId); + +String _userMentionImpl({required bool silent, required String fullName, int? userId}) => + '@${silent ? '_' : ''}**$fullName${userId != null ? '|$userId' : ''}**'; + /// An @-mention of all the users in a conversation, like @**channel**. String wildcardMention(WildcardMentionOption wildcardOption, { required PerAccountStore store, @@ -190,13 +209,11 @@ String quoteAndReplyPlaceholder( PerAccountStore store, { required Message message, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // See note in [quoteAndReply] about asking `mention` to omit the | part. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(#1285) + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}: ' // TODO(#1285) '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n'; } @@ -212,14 +229,14 @@ String quoteAndReply(PerAccountStore store, { required Message message, required String rawContent, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // Could ask `mention` to omit the | part unless the mention is ambiguous… - // but that would mean a linear scan through all users, and the extra noise - // won't much matter with the already probably-long message link in there too. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(#1285) - '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; + // Could ask userMentionFromMessage to omit the | part unless the mention + // is ambiguous… but that would mean a linear scan through all users, + // and the extra noise won't much matter with the already probably-long + // message link in there too. + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}:\n' // TODO(#1285) + '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; } diff --git a/lib/model/content.dart b/lib/model/content.dart index 59f7b41aad..79f3181963 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,11 +341,13 @@ class CodeBlockSpanNode extends ContentNode { } } -abstract class MathNode extends ContentNode { +sealed class MathNode extends ContentNode { const MathNode({ super.debugHtmlNode, required this.texSource, required this.nodes, + this.debugHardFailReason, + this.debugSoftFailReason, }); final String texSource; @@ -357,6 +359,9 @@ abstract class MathNode extends ContentNode { /// fallback instead. final List? nodes; + final KatexParserHardFailReason? debugHardFailReason; + final KatexParserSoftFailReason? debugSoftFailReason; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -369,8 +374,12 @@ abstract class MathNode extends ContentNode { } } -class KatexNode extends ContentNode { - const KatexNode({ +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ required this.styles, required this.text, required this.nodes, @@ -407,6 +416,8 @@ class MathBlockNode extends MathNode implements BlockContentNode { super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -876,6 +887,8 @@ class MathInlineNode extends MathNode implements InlineContentNode { super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -917,7 +930,9 @@ class _ZulipInlineContentParser { return MathInlineNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: debugHtmlNode); + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null); } UserMentionNode? parseUserMention(dom.Element element) { @@ -1072,6 +1087,22 @@ class _ZulipInlineContentParser { return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode); } + if (localName == 'audio' && className.isEmpty) { + final srcAttr = element.attributes['src']; + if (srcAttr == null) return unimplemented(); + + final String title = switch (element.attributes) { + {'title': final titleAttr} => titleAttr, + _ => Uri.tryParse(srcAttr)?.pathSegments.lastOrNull ?? srcAttr, + }; + + final link = LinkNode( + url: srcAttr, + nodes: [TextNode(title)]); + (_linkNodes ??= []).add(link); + return link; + } + if (localName == 'span' && className == 'katex') { return parseInlineMath(element) ?? unimplemented(); } @@ -1624,7 +1655,9 @@ class _ZulipContentParser { result.add(MathBlockNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: kDebugMode ? firstChild : null)); + debugHtmlNode: kDebugMode ? firstChild : null, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); } else { result.add(UnimplementedBlockContentNode(htmlNode: firstChild)); } @@ -1660,7 +1693,9 @@ class _ZulipContentParser { result.add(MathBlockNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: debugHtmlNode)); + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); continue; } } diff --git a/lib/model/database.dart b/lib/model/database.dart index 57910e7a50..ca84fc949c 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -4,6 +4,7 @@ import 'package:drift/remote.dart'; import 'package:sqlite3/common.dart'; import '../log.dart'; +import 'legacy_app_data.dart'; import 'schema_versions.g.dart'; import 'settings.dart'; @@ -24,6 +25,15 @@ class GlobalSettings extends Table { Column get browserPreference => textEnum() .nullable()(); + Column get visitFirstUnread => textEnum() + .nullable()(); + + Column get markReadOnScroll => textEnum() + .nullable()(); + + Column get legacyUpgradeState => textEnum() + .nullable()(); + // If adding a new column to this table, consider whether [BoolGlobalSettings] // can do the job instead (by adding a value to the [BoolGlobalSetting] enum). // That way is more convenient, when it works, because @@ -119,7 +129,7 @@ class AppDatabase extends _$AppDatabase { // information on using the build_runner. // * Write a migration in `_migrationSteps` below. // * Write tests. - static const int latestSchemaVersion = 6; // See note. + static const int latestSchemaVersion = 9; // See note. @override int get schemaVersion => latestSchemaVersion; @@ -174,12 +184,32 @@ class AppDatabase extends _$AppDatabase { from5To6: (m, schema) async { await m.createTable(schema.boolGlobalSettings); }, + from6To7: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.visitFirstUnread); + }, + from7To8: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.markReadOnScroll); + }, + from8To9: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.legacyUpgradeState); + // Earlier versions of this app weren't built to be installed over + // the legacy app. So if upgrading from an earlier version of this app, + // assume there wasn't also the legacy app before that. + await m.database.update(schema.globalSettings).write( + RawValuesInsertable({'legacy_upgrade_state': Constant('noLegacy')})); + } ); Future _createLatestSchema(Migrator m) async { + assert(debugLog('Creating DB schema from scratch.')); await m.createAll(); // Corresponds to `from4to5` above. await into(globalSettings).insert(GlobalSettingsCompanion()); + // Corresponds to (but differs from) part of `from8To9` above. + await migrateLegacyAppData(this); } @override @@ -191,7 +221,7 @@ class AppDatabase extends _$AppDatabase { // This should only ever happen in dev. As a dev convenience, // drop everything from the database and start over. // TODO(log): log schema downgrade as an error - assert(debugLog('Downgrading schema from v$from to v$to.')); + assert(debugLog('Downgrading DB schema from v$from to v$to.')); // In the actual app, the target schema version is always // the latest version as of the code that's being run. @@ -205,6 +235,7 @@ class AppDatabase extends _$AppDatabase { } assert(1 <= from && from <= to && to <= latestSchemaVersion); + assert(debugLog('Upgrading DB schema from v$from to v$to.')); await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps); }); } diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index 99752bdd62..6fdbec74f8 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -22,17 +22,60 @@ class $GlobalSettingsTable extends GlobalSettings ).withConverter($GlobalSettingsTable.$converterthemeSettingn); @override late final GeneratedColumnWithTypeConverter - browserPreference = GeneratedColumn( - 'browser_preference', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ).withConverter( - $GlobalSettingsTable.$converterbrowserPreferencen, - ); + browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterbrowserPreferencen, + ); @override - List get $columns => [themeSetting, browserPreference]; + late final GeneratedColumnWithTypeConverter + visitFirstUnread = + GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertervisitFirstUnreadn, + ); + @override + late final GeneratedColumnWithTypeConverter + markReadOnScroll = + GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertermarkReadOnScrolln, + ); + @override + late final GeneratedColumnWithTypeConverter + legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterlegacyUpgradeStaten, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -57,6 +100,27 @@ class $GlobalSettingsTable extends GlobalSettings data['${effectivePrefix}browser_preference'], ), ), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ), ); } @@ -81,13 +145,46 @@ class $GlobalSettingsTable extends GlobalSettings $converterbrowserPreferencen = JsonTypeConverter2.asNullable( $converterbrowserPreference, ); + static JsonTypeConverter2 + $convertervisitFirstUnread = const EnumNameConverter( + VisitFirstUnreadSetting.values, + ); + static JsonTypeConverter2 + $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable( + $convertervisitFirstUnread, + ); + static JsonTypeConverter2 + $convertermarkReadOnScroll = const EnumNameConverter( + MarkReadOnScrollSetting.values, + ); + static JsonTypeConverter2 + $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable( + $convertermarkReadOnScroll, + ); + static JsonTypeConverter2 + $converterlegacyUpgradeState = const EnumNameConverter( + LegacyUpgradeState.values, + ); + static JsonTypeConverter2 + $converterlegacyUpgradeStaten = JsonTypeConverter2.asNullable( + $converterlegacyUpgradeState, + ); } class GlobalSettingsData extends DataClass implements Insertable { final ThemeSetting? themeSetting; final BrowserPreference? browserPreference; - const GlobalSettingsData({this.themeSetting, this.browserPreference}); + final VisitFirstUnreadSetting? visitFirstUnread; + final MarkReadOnScrollSetting? markReadOnScroll; + final LegacyUpgradeState? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -103,19 +200,47 @@ class GlobalSettingsData extends DataClass ), ); } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread, + ), + ); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll, + ), + ); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState, + ), + ); + } return map; } GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), ); } @@ -130,6 +255,12 @@ class GlobalSettingsData extends DataClass ), browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen .fromJson(serializer.fromJson(json['browserPreference'])), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromJson(serializer.fromJson(json['visitFirstUnread'])), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromJson(serializer.fromJson(json['markReadOnScroll'])), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromJson(serializer.fromJson(json['legacyUpgradeState'])), ); } @override @@ -144,29 +275,62 @@ class GlobalSettingsData extends DataClass browserPreference, ), ), + 'visitFirstUnread': serializer.toJson( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toJson( + visitFirstUnread, + ), + ), + 'markReadOnScroll': serializer.toJson( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toJson( + markReadOnScroll, + ), + ), + 'legacyUpgradeState': serializer.toJson( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toJson( + legacyUpgradeState, + ), + ), }; } GlobalSettingsData copyWith({ Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, ); } @@ -174,43 +338,71 @@ class GlobalSettingsData extends DataClass String toString() { return (StringBuffer('GlobalSettingsData(') ..write('themeSetting: $themeSetting, ') - ..write('browserPreference: $browserPreference') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(themeSetting, browserPreference); + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); @override bool operator ==(Object other) => identical(this, other) || (other is GlobalSettingsData && other.themeSetting == this.themeSetting && - other.browserPreference == this.browserPreference); + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); } class GlobalSettingsCompanion extends UpdateCompanion { final Value themeSetting; final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; final Value rowid; const GlobalSettingsCompanion({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), this.rowid = const Value.absent(), }); GlobalSettingsCompanion.insert({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), this.rowid = const Value.absent(), }); static Insertable custom({ Expression? themeSetting, Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, Expression? rowid, }) { return RawValuesInsertable({ if (themeSetting != null) 'theme_setting': themeSetting, if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, if (rowid != null) 'rowid': rowid, }); } @@ -218,11 +410,17 @@ class GlobalSettingsCompanion extends UpdateCompanion { GlobalSettingsCompanion copyWith({ Value? themeSetting, Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, Value? rowid, }) { return GlobalSettingsCompanion( themeSetting: themeSetting ?? this.themeSetting, browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, rowid: rowid ?? this.rowid, ); } @@ -242,6 +440,27 @@ class GlobalSettingsCompanion extends UpdateCompanion { ), ); } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread.value, + ), + ); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll.value, + ), + ); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState.value, + ), + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -253,6 +472,9 @@ class GlobalSettingsCompanion extends UpdateCompanion { return (StringBuffer('GlobalSettingsCompanion(') ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -325,16 +547,14 @@ class $BoolGlobalSettingsTable extends BoolGlobalSettings BoolGlobalSettingRow map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BoolGlobalSettingRow( - name: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - )!, - value: - attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}value'], - )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, ); } @@ -691,46 +911,40 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { Account map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Account( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, realmUrl: $AccountsTable.$converterrealmUrl.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}realm_url'], )!, ), - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -813,15 +1027,13 @@ class Account extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -875,11 +1087,13 @@ class Account extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); Account copyWithCompanion(AccountsCompanion data) { return Account( @@ -888,22 +1102,18 @@ class Account extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } @@ -1109,12 +1319,18 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder = GlobalSettingsCompanion Function({ Value themeSetting, Value browserPreference, + Value visitFirstUnread, + Value markReadOnScroll, + Value legacyUpgradeState, Value rowid, }); typedef $$GlobalSettingsTableUpdateCompanionBuilder = GlobalSettingsCompanion Function({ Value themeSetting, Value browserPreference, + Value visitFirstUnread, + Value markReadOnScroll, + Value legacyUpgradeState, Value rowid, }); @@ -1138,6 +1354,36 @@ class $$GlobalSettingsTableFilterComposer column: $table.browserPreference, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnWithTypeConverterFilters< + VisitFirstUnreadSetting?, + VisitFirstUnreadSetting, + String + > + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + MarkReadOnScrollSetting?, + MarkReadOnScrollSetting, + String + > + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + LegacyUpgradeState?, + LegacyUpgradeState, + String + > + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$GlobalSettingsTableOrderingComposer @@ -1158,6 +1404,21 @@ class $$GlobalSettingsTableOrderingComposer column: $table.browserPreference, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnOrderings(column), + ); } class $$GlobalSettingsTableAnnotationComposer @@ -1180,6 +1441,24 @@ class $$GlobalSettingsTableAnnotationComposer column: $table.browserPreference, builder: (column) => column, ); + + GeneratedColumnWithTypeConverter + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => column, + ); } class $$GlobalSettingsTableTableManager @@ -1211,25 +1490,30 @@ class $$GlobalSettingsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$GlobalSettingsTableFilterComposer($db: db, $table: table), - createOrderingComposer: - () => - $$GlobalSettingsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: - () => $$GlobalSettingsTableAnnotationComposer( - $db: db, - $table: table, - ), + createFilteringComposer: () => + $$GlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$GlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$GlobalSettingsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = + const Value.absent(), + Value markReadOnScroll = + const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion( themeSetting: themeSetting, browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, rowid: rowid, ), createCompanionCallback: @@ -1237,22 +1521,24 @@ class $$GlobalSettingsTableTableManager Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = + const Value.absent(), + Value markReadOnScroll = + const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion.insert( themeSetting: themeSetting, browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, rowid: rowid, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -1373,18 +1659,12 @@ class $$BoolGlobalSettingsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$BoolGlobalSettingsTableFilterComposer( - $db: db, - $table: table, - ), - createOrderingComposer: - () => $$BoolGlobalSettingsTableOrderingComposer( - $db: db, - $table: table, - ), - createComputedFieldComposer: - () => $$BoolGlobalSettingsTableAnnotationComposer( + createFilteringComposer: () => + $$BoolGlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BoolGlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BoolGlobalSettingsTableAnnotationComposer( $db: db, $table: table, ), @@ -1408,16 +1688,9 @@ class $$BoolGlobalSettingsTableTableManager value: value, rowid: rowid, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -1645,12 +1918,12 @@ class $$AccountsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$AccountsTableFilterComposer($db: db, $table: table), - createOrderingComposer: - () => $$AccountsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: - () => $$AccountsTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$AccountsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AccountsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AccountsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), @@ -1695,16 +1968,9 @@ class $$AccountsTableTableManager zulipFeatureLevel: zulipFeatureLevel, ackedPushToken: ackedPushToken, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 670c574c12..0923fdab79 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -115,13 +115,7 @@ mixin EmojiStore { /// /// See description in the web code: /// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21 - // Someday this list may start varying rather than being hard-coded, - // and then this will become a non-static member on EmojiStore. - // For now, though, the fact it's constant is convenient when writing - // tests of the logic that uses this data; so we guarantee it in the API. - static Iterable get popularEmojiCandidates { - return EmojiStoreImpl._popularCandidates; - } + Iterable popularEmojiCandidates(); Iterable allEmojiCandidates(); @@ -218,36 +212,53 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { /// retrieving the data. Map>? _serverEmojiData; - static final _popularCandidates = _generatePopularCandidates(); + List? _popularCandidates; - static List _generatePopularCandidates() { - EmojiCandidate candidate(String emojiCode, String emojiUnicode, - List names) { - final emojiName = names.removeAt(0); - assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + @override + Iterable popularEmojiCandidates() { + return _popularCandidates ??= _generatePopularCandidates(); + } + + List _generatePopularCandidates() { + EmojiCandidate candidate(String emojiCode, List names) { + final [emojiName, ...aliases] = names; + final emojiUnicode = tryParseEmojiCodeToUnicode(emojiCode)!; return EmojiCandidate(emojiType: ReactionType.unicodeEmoji, - emojiCode: emojiCode, emojiName: emojiName, aliases: names, + emojiCode: emojiCode, emojiName: emojiName, aliases: aliases, emojiDisplay: UnicodeEmojiDisplay( emojiName: emojiName, emojiUnicode: emojiUnicode)); } - return [ - // This list should match web: - // https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29 - candidate('1f44d', '👍', ['+1', 'thumbs_up', 'like']), - candidate('1f389', '🎉', ['tada']), - candidate('1f642', '🙂', ['smile']), - candidate( '2764', '❤', ['heart', 'love', 'love_you']), - candidate('1f6e0', '🛠', ['working_on_it', 'hammer_and_wrench', 'tools']), - candidate('1f419', '🐙', ['octopus']), - ]; + if (_serverEmojiData == null) return []; + + final result = []; + for (final emojiCode in _popularEmojiCodesList) { + final names = _serverEmojiData![emojiCode]; + if (names == null) continue; // TODO(log) + result.add(candidate(emojiCode, names)); + } + return result; } - static final _popularEmojiCodes = (() { - assert(_popularCandidates.every((c) => - c.emojiType == ReactionType.unicodeEmoji)); - return Set.of(_popularCandidates.map((c) => c.emojiCode)); + /// Codes for the popular emoji, in order; all are Unicode emoji. + // This list should match web: + // https://github.com/zulip/zulip/blob/9feba0f16/web/shared/src/typeahead.ts#L22-L29 + static final List _popularEmojiCodesList = (() { + String check(String emojiCode, String emojiUnicode) { + assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + return emojiCode; + } + return [ + check('1f44d', '👍'), + check('1f389', '🎉'), + check('1f642', '🙂'), + check('2764', '❤'), + check('1f6e0', '🛠'), + check('1f419', '🐙'), + ]; })(); + static final Set _popularEmojiCodes = Set.of(_popularEmojiCodesList); + static bool _isPopularEmoji(EmojiCandidate candidate) { return candidate.emojiType == ReactionType.unicodeEmoji && _popularEmojiCodes.contains(candidate.emojiCode); @@ -307,7 +318,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { // Include the "popular" emoji, in their canonical order // relative to each other. - results.addAll(_popularCandidates); + results.addAll(popularEmojiCandidates()); final namesOverridden = { for (final emoji in activeRealmEmoji) emoji.name, @@ -366,6 +377,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; + _popularCandidates = null; _allEmojiCandidates = null; } diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index 749f60698c..92d6db1687 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -92,6 +92,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write(element.operand.toString()); case ApiNarrowMessageId(): fragment.write(element.operand.toString()); + case ApiNarrowSearch(): + fragment.write(_encodeHashComponent(element.operand)); } } @@ -109,22 +111,43 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { return result; } -/// A [Narrow] from a given URL, on `store`'s realm. +/// The result of parsing some URL within a Zulip realm, +/// when the URL corresponds to some page in this app. +sealed class InternalLink { + InternalLink({required this.realmUrl}); + + final Uri realmUrl; +} + +/// The result of parsing some URL that points to a narrow on a Zulip realm, +/// when the narrow is of a type that this app understands. +class NarrowLink extends InternalLink { + NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl}); + + final Narrow narrow; + final int? nearMessageId; +} + +/// Try to parse the given URL as a page in this app, on `store`'s realm. /// /// `url` must already be a result from [PerAccountStore.tryResolveUrl] /// on `store`. /// -/// Returns `null` if any of the operator/operand pairs are invalid. +/// Returns null if the URL isn't on this realm, +/// or isn't a valid Zulip URL, +/// or isn't currently supported as leading to a page in this app. /// +/// In particular this will return null if `url` is a `/#narrow/…` URL +/// and any of the operator/operand pairs are invalid. /// Since narrow links can combine operators in ways our [Narrow] type can't /// represent, this can also return null for valid narrow links. /// /// This can also return null for some valid narrow links that our Narrow /// type *could* accurately represent. We should try to understand these -/// better, but some kinds will be rare, even unheard-of: +/// better, but some kinds will be rare, even unheard-of. For example: /// #narrow/stream/1-announce/stream/1-announce (duplicated operator) -// TODO(#252): handle all valid narrow links, returning a search narrow -Narrow? parseInternalLink(Uri url, PerAccountStore store) { +// TODO(#1661): handle all valid narrow links, returning a search narrow +InternalLink? parseInternalLink(Uri url, PerAccountStore store) { if (!_isInternalLink(url, store.realmUrl)) return null; final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment); @@ -155,7 +178,7 @@ bool _isInternalLink(Uri url, Uri realmUrl) { return (category, segments); } -Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { +NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore store) { assert(segments.isNotEmpty); assert(segments.length.isEven); @@ -164,6 +187,7 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { ApiNarrowDm? dmElement; ApiNarrowWith? withElement; Set isElementOperands = {}; + int? nearMessageId; for (var i = 0; i < segments.length; i += 2) { final (operator, negated) = _parseOperator(segments[i]); @@ -201,14 +225,18 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { // It is fine to have duplicates of the same [IsOperand]. isElementOperands.add(IsOperand.fromRawString(operand)); - case _NarrowOperator.near: // TODO(#82): support for near - continue; + case _NarrowOperator.near: + if (nearMessageId != null) return null; + final messageId = int.tryParse(operand, radix: 10); + if (messageId == null) return null; + nearMessageId = messageId; case _NarrowOperator.unknown: return null; } } + final Narrow? narrow; if (isElementOperands.isNotEmpty) { if (streamElement != null || topicElement != null || dmElement != null || withElement != null) { return null; @@ -216,9 +244,9 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (isElementOperands.length > 1) return null; switch (isElementOperands.single) { case IsOperand.mentioned: - return const MentionsNarrow(); + narrow = const MentionsNarrow(); case IsOperand.starred: - return const StarredMessagesNarrow(); + narrow = const StarredMessagesNarrow(); case IsOperand.dm: case IsOperand.private: case IsOperand.alerted: @@ -230,17 +258,20 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { } } else if (dmElement != null) { if (streamElement != null || topicElement != null || withElement != null) return null; - return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); + narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); } else if (streamElement != null) { final streamId = streamElement.operand; if (topicElement != null) { - return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); + narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); } else { if (withElement != null) return null; - return ChannelNarrow(streamId); + narrow = ChannelNarrow(streamId); } + } else { + return null; } - return null; + + return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl); } @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 709f91b4b2..922546c676 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,4 +1,7 @@ +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:html/dom.dart' as dom; import '../log.dart'; @@ -6,10 +9,40 @@ import 'binding.dart'; import 'content.dart'; import 'settings.dart'; +/// The failure reason in case the KaTeX parser encountered a +/// `_KatexHtmlParseError` exception. +/// +/// Generally this means that parser encountered an unexpected HTML structure, +/// an unsupported HTML node, or an unexpected inline CSS style or CSS class on +/// a specific node. +class KatexParserHardFailReason { + const KatexParserHardFailReason({ + required this.error, + required this.stackTrace, + }); + + final String error; + final StackTrace stackTrace; +} + +/// The failure reason in case the KaTeX parser found an unsupported +/// CSS class or unsupported inline CSS style property. +class KatexParserSoftFailReason { + const KatexParserSoftFailReason({ + this.unsupportedCssClasses = const [], + this.unsupportedInlineCssProperties = const [], + }); + + final List unsupportedCssClasses; + final List unsupportedInlineCssProperties; +} + class MathParserResult { const MathParserResult({ required this.texSource, required this.nodes, + this.hardFailReason, + this.softFailReason, }); final String texSource; @@ -20,6 +53,9 @@ class MathParserResult { /// CSS style, indicating that the widget should render the [texSource] as a /// fallback instead. final List? nodes; + + final KatexParserHardFailReason? hardFailReason; + final KatexParserSoftFailReason? softFailReason; } /// Parses the HTML spans containing KaTeX HTML tree. @@ -85,21 +121,33 @@ MathParserResult? parseMath(dom.Element element, { required bool block }) { final flagForceRenderKatex = globalSettings.getBool(BoolGlobalSetting.forceRenderKatex); + KatexParserHardFailReason? hardFailReason; + KatexParserSoftFailReason? softFailReason; List? nodes; if (flagRenderKatex) { final parser = _KatexParser(); try { nodes = parser.parseKatexHtml(katexHtmlElement); - } on KatexHtmlParseError catch (e, st) { + } on _KatexHtmlParseError catch (e, st) { assert(debugLog('$e\n$st')); + hardFailReason = KatexParserHardFailReason( + error: e.message ?? 'unknown', + stackTrace: st); } if (parser.hasError && !flagForceRenderKatex) { nodes = null; + softFailReason = KatexParserSoftFailReason( + unsupportedCssClasses: parser.unsupportedCssClasses, + unsupportedInlineCssProperties: parser.unsupportedInlineCssProperties); } } - return MathParserResult(nodes: nodes, texSource: texSource); + return MathParserResult( + nodes: nodes, + texSource: texSource, + hardFailReason: hardFailReason, + softFailReason: softFailReason); } else { return null; } @@ -109,23 +157,24 @@ class _KatexParser { bool get hasError => _hasError; bool _hasError = false; - void _logError(String message) { - assert(debugLog(message)); - _hasError = true; - } + final unsupportedCssClasses = []; + final unsupportedInlineCssProperties = []; List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); - return _parseChildSpans(element); + return _parseChildSpans(element.nodes); } - List _parseChildSpans(dom.Element element) { - return List.unmodifiable(element.nodes.map((node) { + List _parseChildSpans(List nodes) { + return List.unmodifiable(nodes.map((node) { if (node case dom.Element(localName: 'span')) { return _parseSpan(node); } else { - throw KatexHtmlParseError(); + throw _KatexHtmlParseError( + node is dom.Element + ? 'unsupported html node: ${node.localName}' + : 'unsupported html node'); } })); } @@ -136,6 +185,10 @@ class _KatexParser { KatexNode _parseSpan(dom.Element element) { // TODO maybe check if the sequence of ancestors matter for spans. + final debugHtmlNode = kDebugMode ? element : null; + + final inlineStyles = _parseSpanInlineStyles(element); + // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -275,20 +328,40 @@ class _KatexParser { fontStyle = KatexSpanFontStyle.normal; // TODO handle skipped class declarations between .mainrm and + // .mspace . + + case 'mspace': + // .mspace { display: inline-block; } + // A .mspace span's children are always either empty, + // a no-break space " " (== "\xa0"), + // or one span.mtight containing a no-break space. + // TODO enforce that constraint on .mspace spans in parsing + // So `display: inline-block` has no effect compared to + // the initial `display: inline`. + break; + + // TODO handle skipped class declarations between .mspace and + // .msupsub . + + case 'msupsub': + // .msupsub { text-align: left; } + textAlign = KatexSpanTextAlign.left; + + // TODO handle skipped class declarations between .msupsub and // .sizing . case 'sizing': case 'fontsize-ensurer': // .sizing, // .fontsize-ensurer { ... } - if (index + 2 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 2 > spanClasses.length) throw _KatexHtmlParseError(); final resetSizeClass = spanClasses[index++]; final sizeClass = spanClasses[index++]; final resetSizeClassSuffix = _resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1); - if (resetSizeClassSuffix == null) throw KatexHtmlParseError(); + if (resetSizeClassSuffix == null) throw _KatexHtmlParseError(); final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1); - if (sizeClassSuffix == null) throw KatexHtmlParseError(); + if (sizeClassSuffix == null) throw _KatexHtmlParseError(); const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488]; @@ -296,13 +369,13 @@ class _KatexParser { final sizeIdx = int.parse(sizeClassSuffix, radix: 10); // These indexes start at 1. - if (resetSizeIdx > sizes.length) throw KatexHtmlParseError(); - if (sizeIdx > sizes.length) throw KatexHtmlParseError(); + if (resetSizeIdx > sizes.length) throw _KatexHtmlParseError(); + if (sizeIdx > sizes.length) throw _KatexHtmlParseError(); fontSizeEm = sizes[sizeIdx - 1] / sizes[resetSizeIdx - 1]; case 'delimsizing': // .delimsizing { ... } - if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); fontFamily = switch (spanClasses[index++]) { 'size1' => 'KaTeX_Size1', 'size2' => 'KaTeX_Size2', @@ -310,31 +383,50 @@ class _KatexParser { 'size4' => 'KaTeX_Size4', 'mult' => // TODO handle nested spans with `.delim-size{1,4}` class. - throw KatexHtmlParseError(), - _ => throw KatexHtmlParseError(), + throw _KatexHtmlParseError(), + _ => throw _KatexHtmlParseError(), }; // TODO handle .nulldelimiter and .delimcenter . case 'op-symbol': // .op-symbol { ... } - if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); fontFamily = switch (spanClasses[index++]) { 'small-op' => 'KaTeX_Size1', 'large-op' => 'KaTeX_Size2', - _ => throw KatexHtmlParseError(), + _ => throw _KatexHtmlParseError(), }; // TODO handle more classes from katex.scss case 'mord': case 'mopen': + case 'mtight': + case 'text': + case 'mrel': + case 'mop': + case 'mclose': + case 'minner': + case 'mbin': + case 'mpunct': + case 'nobreak': + case 'allowbreak': + case 'mathdefault': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. + // (Why are they there if they're not used? The story seems to be: + // they were used in KaTeX's CSS in the past, before 2020 or so; and + // they're still used internally by KaTeX in producing the HTML. + // https://github.com/KaTeX/KaTeX/issues/2194#issuecomment-584703052 + // https://github.com/KaTeX/KaTeX/issues/3344 + // ) break; default: - _logError('KaTeX: Unsupported CSS class: $spanClass'); + assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); + unsupportedCssClasses.add(spanClass); + _hasError = true; } } final styles = KatexSpanStyles( @@ -350,14 +442,67 @@ class _KatexParser { if (element.nodes case [dom.Text(:final data)]) { text = data; } else { - spans = _parseChildSpans(element); + spans = _parseChildSpans(element.nodes); } - if (text == null && spans == null) throw KatexHtmlParseError(); + if (text == null && spans == null) throw _KatexHtmlParseError(); - return KatexNode( - styles: styles, + return KatexSpanNode( + styles: inlineStyles != null + ? styles.merge(inlineStyles) + : styles, text: text, - nodes: spans); + nodes: spans, + debugHtmlNode: debugHtmlNode); + } + + KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { + if (element.attributes case {'style': final styleStr}) { + // `package:csslib` doesn't seem to have a way to parse inline styles: + // https://github.com/dart-lang/tools/issues/1173 + // So, work around that by wrapping it in a universal declaration. + final stylesheet = css_parser.parse('*{$styleStr}'); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { + double? heightEm; + + for (final declaration in rule.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + switch (property) { + case 'height': + heightEm = _getEm(expression); + if (heightEm != null) continue; + } + + // TODO handle more CSS properties + assert(debugLog('KaTeX: Unsupported CSS expression:' + ' ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + } else { + throw _KatexHtmlParseError(); + } + } + + return KatexSpanStyles( + heightEm: heightEm, + ); + } else { + throw _KatexHtmlParseError(); + } + } + return null; + } + + /// Returns the CSS `em` unit value if the given [expression] is actually an + /// `em` unit expression, else returns null. + double? _getEm(css_visitor.Expression expression) { + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + return null; } } @@ -378,6 +523,8 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { + final double? heightEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -385,6 +532,7 @@ class KatexSpanStyles { final KatexSpanTextAlign? textAlign; const KatexSpanStyles({ + this.heightEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -395,6 +543,7 @@ class KatexSpanStyles { @override int get hashCode => Object.hash( 'KatexSpanStyles', + heightEm, fontFamily, fontSizeEm, fontWeight, @@ -405,6 +554,7 @@ class KatexSpanStyles { @override bool operator ==(Object other) { return other is KatexSpanStyles && + other.heightEm == heightEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -415,6 +565,7 @@ class KatexSpanStyles { @override String toString() { final args = []; + if (heightEm != null) args.add('heightEm: $heightEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -422,11 +573,30 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } + + /// Creates a new [KatexSpanStyles] with current and [other]'s styles merged. + /// + /// The styles in [other] take precedence and any missing styles in [other] + /// are filled in with current styles, if present. + /// + /// This similar to the behaviour of [TextStyle.merge], if the given style + /// had `inherit` set to true. + KatexSpanStyles merge(KatexSpanStyles other) { + return KatexSpanStyles( + heightEm: other.heightEm ?? heightEm, + fontFamily: other.fontFamily ?? fontFamily, + fontSizeEm: other.fontSizeEm ?? fontSizeEm, + fontStyle: other.fontStyle ?? fontStyle, + fontWeight: other.fontWeight ?? fontWeight, + textAlign: other.textAlign ?? textAlign, + ); + } } -class KatexHtmlParseError extends Error { +class _KatexHtmlParseError extends Error { final String? message; - KatexHtmlParseError([this.message]); + + _KatexHtmlParseError([this.message]); @override String toString() { diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart new file mode 100644 index 0000000000..5f6197f0fc --- /dev/null +++ b/lib/model/legacy_app_data.dart @@ -0,0 +1,508 @@ +/// Logic for reading from the legacy app's data, on upgrade to this app. +/// +/// Many of the details here correspond to specific parts of the +/// legacy app's source code. +/// See . +// TODO(#1593): write tests for this file +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart' as drift; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart'; + +import '../log.dart'; +import 'database.dart'; +import 'settings.dart'; + +part 'legacy_app_data.g.dart'; + +Future migrateLegacyAppData(AppDatabase db) async { + assert(debugLog("Migrating legacy app data...")); + final legacyData = await readLegacyAppData(); + if (legacyData == null) { + assert(debugLog("... no legacy app data found.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.noLegacy); + return; + } + + assert(debugLog("Found settings: ${legacyData.settings?.toJson()}")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.found); + final settings = legacyData.settings; + if (settings != null) { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + // TODO(#1139) apply settings.language + themeSetting: switch (settings.theme) { + // The legacy app has just two values for this setting: light and dark, + // where light is the default. Map that default to the new default, + // which is to follow the system-wide setting. + // We planned the same change for the legacy app (but were + // foiled by React Native): + // https://github.com/zulip/zulip-mobile/issues/5533 + // More-recent discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2147418577 + LegacyAppThemeSetting.default_ => drift.Value.absent(), + LegacyAppThemeSetting.night => drift.Value(ThemeSetting.dark), + }, + browserPreference: switch (settings.browser) { + LegacyAppBrowserPreference.embedded => drift.Value(BrowserPreference.inApp), + LegacyAppBrowserPreference.external => drift.Value(BrowserPreference.external), + LegacyAppBrowserPreference.default_ => drift.Value.absent(), + }, + markReadOnScroll: switch (settings.markMessagesReadOnScroll) { + // The legacy app's default was "always". + // In this app, that would mix poorly with the VisitFirstUnreadSetting + // default of "conversations"; so translate the old default + // to the new default of "conversations". + LegacyAppMarkMessagesReadOnScroll.always => + drift.Value(MarkReadOnScrollSetting.conversations), + LegacyAppMarkMessagesReadOnScroll.never => + drift.Value(MarkReadOnScrollSetting.never), + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly => + drift.Value(MarkReadOnScrollSetting.conversations), + }, + )); + } + + assert(debugLog("Found ${legacyData.accounts?.length} accounts:")); + for (final account in legacyData.accounts ?? []) { + assert(debugLog(" account: ${account.toJson()..['apiKey'] = 'redacted'}")); + if (account.apiKey.isEmpty) { + // This represents the user having logged out of this account. + // (See `Auth.apiKey` in src/api/transportTypes.js .) + // In this app, when a user logs out of an account, + // the account is removed from the accounts list. So remove this account. + assert(debugLog(" (account ignored because had been logged out)")); + continue; + } + if (account.userId == null + || account.zulipVersion == null + || account.zulipFeatureLevel == null) { + // The legacy app either never loaded server data for this account, + // or last did so on an ancient version of the app. + // (See docs and comments on these properties in src/types.js . + // Specifically, the latest added of these was userId, in commit 4fdefb09b + // (#M4968), released in v27.170 in 2021-09.) + // Drop the account. + assert(debugLog(" (account ignored because missing metadata)")); + continue; + } + try { + await db.createAccount(AccountsCompanion.insert( + realmUrl: account.realm, + userId: account.userId!, + email: account.email, + apiKey: account.apiKey, + zulipVersion: account.zulipVersion!, + // no zulipMergeBase; legacy app didn't record it + zulipFeatureLevel: account.zulipFeatureLevel!, + // This app doesn't yet maintain ackedPushToken (#322), so avoid recording + // a value that would then be allowed to get stale. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2148817025 + // TODO(#322): apply ackedPushToken + // ackedPushToken: drift.Value(account.ackedPushToken), + )); + } on AccountAlreadyExistsException { + // There's one known way this can actually happen: the legacy app doesn't + // prevent duplicates on (realm, userId), only on (realm, email). + // + // So if e.g. the user changed their email on an account at some point + // in the past, and didn't go and delete the old version from the + // list of accounts, then the old version (the one later in the list, + // since the legacy app orders accounts by recency) will get dropped here. + assert(debugLog(" (account ignored because duplicate)")); + continue; + } + } + + assert(debugLog("Done migrating legacy app data.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.migrated); +} + +Future _setLegacyUpgradeState(AppDatabase db, LegacyUpgradeState value) async { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + legacyUpgradeState: drift.Value(value))); +} + +Future readLegacyAppData() async { + final LegacyAppDatabase db; + try { + final sqlDb = sqlite3.open(await LegacyAppDatabase._filename()); + + // For writing tests (but more refactoring needed): + // sqlDb = sqlite3.openInMemory(); + + db = LegacyAppDatabase(sqlDb); + } catch (_) { + // Presumably the legacy database just doesn't exist, + // e.g. because this is a fresh install, not an upgrade from the legacy app. + return null; + } + + try { + if (db.migrationVersion() != 1) { + // The data is ancient. + return null; // TODO(log) + } + + final migrationsState = db.getDecodedItem('reduxPersist:migrations', + LegacyAppMigrationsState.fromJson); + final migrationsVersion = migrationsState?.version; + if (migrationsVersion == null) { + // The data never got written in the first place, + // at least not coherently. + return null; // TODO(log) + } + if (migrationsVersion < 58) { + // The data predates a migration that affected data we'll try to read. + // Namely migration 58, from commit 49ed2ef5d, PR #5656, 2023-02. + return null; // TODO(log) + } + if (migrationsVersion > 66) { + // The data is from a future schema version this app is unaware of. + return null; // TODO(log) + } + + final settingsStr = db.getItem('reduxPersist:settings'); + final accountsStr = db.getItem('reduxPersist:accounts'); + try { + return LegacyAppData.fromJson({ + 'settings': settingsStr == null ? null : jsonDecode(settingsStr), + 'accounts': accountsStr == null ? null : jsonDecode(accountsStr), + }); + } catch (_) { + return null; // TODO(log) + } + } on SqliteException { + return null; // TODO(log) + } +} + +class LegacyAppDatabase { + LegacyAppDatabase(this._db); + + final Database _db; + + static Future _filename() async { + const baseName = 'zulip.db'; // from AsyncStorageImpl._initDb + + final dir = await switch (defaultTargetPlatform) { + // See node_modules/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt + // and the method SQLiteModule.pathForDatabaseName there: + // works out to "${mContext.filesDir}/SQLite/$name", + // so starting from: + // https://developer.android.com/reference/kotlin/android/content/Context#getFilesDir() + // That's what path_provider's getApplicationSupportDirectory gives. + // (The latter actually has a fallback when Android's getFilesDir + // returns null. But the Android docs say that can't happen. If it does, + // SQLiteModule would just fail to make a database, and the legacy app + // wouldn't have managed to store anything in the first place.) + TargetPlatform.android => getApplicationSupportDirectory(), + + // See node_modules/expo-sqlite/ios/EXSQLite/EXSQLite.m + // and the method `pathForDatabaseName:` there: + // works out to "${fileSystem.documentDirectory}/SQLite/$name", + // The base directory there comes from: + // node_modules/expo-modules-core/ios/Interfaces/FileSystem/EXFileSystemInterface.h + // node_modules/expo-file-system/ios/EXFileSystem/EXFileSystem.m + // so ultimately from an expression: + // NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) + // which means here: + // https://developer.apple.com/documentation/foundation/nssearchpathfordirectoriesindomains(_:_:_:)?language=objc + // https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory/documentdirectory?language=objc + // That's what path_provider's getApplicationDocumentsDirectory gives. + TargetPlatform.iOS => getApplicationDocumentsDirectory(), + + // On other platforms, there is no Zulip legacy app that this app replaces. + // So there's nothing to migrate. + _ => throw Exception(), + }; + + return '${dir.path}/SQLite/$baseName'; + } + + /// The migration version of the AsyncStorage database as a whole + /// (not to be confused with the version within `state.migrations`). + /// + /// This is always 1 since it was introduced, + /// in commit caf3bf999 in 2022-04. + /// + /// Corresponds to portions of AsyncStorageImpl._migrate . + int migrationVersion() { + final rows = _db.select('SELECT version FROM migration LIMIT 1'); + return rows.single.values.single as int; + } + + T? getDecodedItem(String key, T Function(Map) fromJson) { + final valueStr = getItem(key); + if (valueStr == null) return null; + + try { + return fromJson(jsonDecode(valueStr) as Map); + } catch (_) { + return null; // TODO(log) + } + } + + /// Corresponds to CompressedAsyncStorage.getItem. + String? getItem(String key) { + final item = getItemRaw(key); + if (item == null) return null; + if (item.startsWith('z')) { + // A leading 'z' marks Zulip compression. + // (It can't be the original uncompressed value, because all our values + // are JSON, and no JSON encoding starts with a 'z'.) + + if (defaultTargetPlatform != TargetPlatform.android) { + return null; // TODO(log) + } + + /// Corresponds to `header` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + const header = 'z|zlib base64|'; + if (!item.startsWith(header)) { + return null; // TODO(log) + } + + // These steps correspond to `decompress` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + final encodedSplit = item.substring(header.length); + // Not sure how newlines get there into the data; but empirically + // they do, after each 76 characters of `encodedSplit`. + final encoded = encodedSplit.replaceAll('\n', ''); + try { + final compressedBytes = base64Decode(encoded); + final uncompressedBytes = zlib.decoder.convert(compressedBytes); + return utf8.decode(uncompressedBytes); + } catch (_) { + return null; // TODO(log) + } + } + return item; + } + + /// Corresponds to AsyncStorageImpl.getItem. + String? getItemRaw(String key) { + final rows = _db.select('SELECT value FROM keyvalue WHERE key = ?', [key]); + final row = rows.firstOrNull; + if (row == null) return null; + return row.values.single as String; + } + + /// Corresponds to AsyncStorageImpl.getAllKeys. + List getAllKeys() { + final rows = _db.select('SELECT key FROM keyvalue'); + return [for (final r in rows) r.values.single as String]; + } +} + +/// Represents the data from the legacy app's database, +/// so far as it's relevant for this app. +/// +/// The full set of data in the legacy app's in-memory store is described by +/// the type `GlobalState` in src/reduxTypes.js . +/// Within that, the data it stores in the database is the data at the keys +/// listed in `storeKeys` and `cacheKeys` in src/boot/store.js . +/// The data under `cacheKeys` lives on the server and the app re-fetches it +/// upon each startup anyway; +/// so only the data under `storeKeys` is relevant for migrating to this app. +/// +/// Within the data under `storeKeys`, some portions are also ignored +/// for specific reasons described explicitly in comments on these types. +@JsonSerializable() +class LegacyAppData { + // The `state.migrations` data gets read and used before attempting to + // deserialize the data that goes into this class. + // final LegacyAppMigrationsState migrations; // handled separately + + final LegacyAppGlobalSettingsState? settings; + final List? accounts; + + // final Map drafts; // ignore; inherently transient + + // final List outbox; // ignore; inherently transient + + LegacyAppData({ + required this.settings, + required this.accounts, + }); + + factory LegacyAppData.fromJson(Map json) => + _$LegacyAppDataFromJson(json); + + Map toJson() => _$LegacyAppDataToJson(this); +} + +/// Corresponds to type `MigrationsState` in src/reduxTypes.js . +@JsonSerializable() +class LegacyAppMigrationsState { + final int? version; + + LegacyAppMigrationsState({required this.version}); + + factory LegacyAppMigrationsState.fromJson(Map json) => + _$LegacyAppMigrationsStateFromJson(json); + + Map toJson() => _$LegacyAppMigrationsStateToJson(this); +} + +/// Corresponds to type `GlobalSettingsState` in src/reduxTypes.js . +/// +/// The remaining data found at key `settings` in the overall data, +/// described by type `PerAccountSettingsState`, lives on the server +/// in the same way as the data under the keys in `cacheKeys`, +/// and so is ignored here. +@JsonSerializable() +class LegacyAppGlobalSettingsState { + final String language; + final LegacyAppThemeSetting theme; + final LegacyAppBrowserPreference browser; + + // Ignored because the legacy app hadn't used it since 2017. + // See discussion in commit zulip-mobile@761e3edb4 (from 2018). + // final bool experimentalFeaturesEnabled; // ignore + + final LegacyAppMarkMessagesReadOnScroll markMessagesReadOnScroll; + + LegacyAppGlobalSettingsState({ + required this.language, + required this.theme, + required this.browser, + required this.markMessagesReadOnScroll, + }); + + factory LegacyAppGlobalSettingsState.fromJson(Map json) => + _$LegacyAppGlobalSettingsStateFromJson(json); + + Map toJson() => _$LegacyAppGlobalSettingsStateToJson(this); +} + +/// Corresponds to type `ThemeSetting` in src/reduxTypes.js . +enum LegacyAppThemeSetting { + @JsonValue('default') + default_, + night; +} + +/// Corresponds to type `BrowserPreference` in src/reduxTypes.js . +enum LegacyAppBrowserPreference { + embedded, + external, + @JsonValue('default') + default_, +} + +/// Corresponds to the type `GlobalSettingsState['markMessagesReadOnScroll']` +/// in src/reduxTypes.js . +@JsonEnum(fieldRename: FieldRename.kebab) +enum LegacyAppMarkMessagesReadOnScroll { + always, never, conversationViewsOnly, +} + +/// Corresponds to type `Account` in src/types.js . +@JsonSerializable() +class LegacyAppAccount { + // These three come from type Auth in src/api/transportTypes.js . + @_LegacyAppUrlJsonConverter() + final Uri realm; + final String apiKey; + final String email; + + final int? userId; + + @_LegacyAppZulipVersionJsonConverter() + final String? zulipVersion; + + final int? zulipFeatureLevel; + + final String? ackedPushToken; + + // These three are ignored because this app doesn't currently have such + // notices or banners for them to control; and because if we later introduce + // such things, it's a pretty mild glitch to have them reappear, once, + // after a once-in-N-years major upgrade to the app. + // final DateTime? lastDismissedServerPushSetupNotice; // ignore + // final DateTime? lastDismissedServerNotifsExpiringBanner; // ignore + // final bool silenceServerPushSetupWarnings; // ignore + + LegacyAppAccount({ + required this.realm, + required this.apiKey, + required this.email, + required this.userId, + required this.zulipVersion, + required this.zulipFeatureLevel, + required this.ackedPushToken, + }); + + factory LegacyAppAccount.fromJson(Map json) => + _$LegacyAppAccountFromJson(json); + + Map toJson() => _$LegacyAppAccountToJson(this); +} + +/// This and its subclasses correspond to portions of src/storage/replaceRevive.js . +/// +/// (The rest of the conversions in that file are for types that don't appear +/// in the portions of the legacy app's state we care about.) +sealed class _LegacyAppJsonConverter extends JsonConverter> { + const _LegacyAppJsonConverter(); + + String get serializedTypeName; + + T fromJsonData(Object? json); + + Object? toJsonData(T value); + + /// Corresponds to `SERIALIZED_TYPE_FIELD_NAME`. + static const _serializedTypeFieldName = '__serializedType__'; + + @override + T fromJson(Map json) { + final actualTypeName = json[_serializedTypeFieldName]; + if (actualTypeName != serializedTypeName) { + throw FormatException("unexpected $_serializedTypeFieldName: $actualTypeName"); + } + return fromJsonData(json['data']); + } + + @override + Map toJson(T object) { + return { + _serializedTypeFieldName: serializedTypeName, + 'data': toJsonData(object), + }; + } +} + +class _LegacyAppUrlJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppUrlJsonConverter(); + + @override + String get serializedTypeName => 'URL'; + + @override + Uri fromJsonData(Object? json) => Uri.parse(json as String); + + @override + Object? toJsonData(Uri value) => value.toString(); +} + +/// Corresponds to type `ZulipVersion`. +/// +/// This new app skips the parsing logic of the legacy app's ZulipVersion type, +/// and just uses the raw string. +class _LegacyAppZulipVersionJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppZulipVersionJsonConverter(); + + @override + String get serializedTypeName => 'ZulipVersion'; + + @override + String fromJsonData(Object? json) => json as String; + + @override + Object? toJsonData(String value) => value; +} diff --git a/lib/model/legacy_app_data.g.dart b/lib/model/legacy_app_data.g.dart new file mode 100644 index 0000000000..e619745e38 --- /dev/null +++ b/lib/model/legacy_app_data.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'legacy_app_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LegacyAppData _$LegacyAppDataFromJson(Map json) => + LegacyAppData( + settings: json['settings'] == null + ? null + : LegacyAppGlobalSettingsState.fromJson( + json['settings'] as Map, + ), + accounts: (json['accounts'] as List?) + ?.map((e) => LegacyAppAccount.fromJson(e as Map)) + .toList(), + ); + +Map _$LegacyAppDataToJson(LegacyAppData instance) => + { + 'settings': instance.settings, + 'accounts': instance.accounts, + }; + +LegacyAppMigrationsState _$LegacyAppMigrationsStateFromJson( + Map json, +) => LegacyAppMigrationsState(version: (json['version'] as num?)?.toInt()); + +Map _$LegacyAppMigrationsStateToJson( + LegacyAppMigrationsState instance, +) => {'version': instance.version}; + +LegacyAppGlobalSettingsState _$LegacyAppGlobalSettingsStateFromJson( + Map json, +) => LegacyAppGlobalSettingsState( + language: json['language'] as String, + theme: $enumDecode(_$LegacyAppThemeSettingEnumMap, json['theme']), + browser: $enumDecode(_$LegacyAppBrowserPreferenceEnumMap, json['browser']), + markMessagesReadOnScroll: $enumDecode( + _$LegacyAppMarkMessagesReadOnScrollEnumMap, + json['markMessagesReadOnScroll'], + ), +); + +Map _$LegacyAppGlobalSettingsStateToJson( + LegacyAppGlobalSettingsState instance, +) => { + 'language': instance.language, + 'theme': _$LegacyAppThemeSettingEnumMap[instance.theme]!, + 'browser': _$LegacyAppBrowserPreferenceEnumMap[instance.browser]!, + 'markMessagesReadOnScroll': + _$LegacyAppMarkMessagesReadOnScrollEnumMap[instance + .markMessagesReadOnScroll]!, +}; + +const _$LegacyAppThemeSettingEnumMap = { + LegacyAppThemeSetting.default_: 'default', + LegacyAppThemeSetting.night: 'night', +}; + +const _$LegacyAppBrowserPreferenceEnumMap = { + LegacyAppBrowserPreference.embedded: 'embedded', + LegacyAppBrowserPreference.external: 'external', + LegacyAppBrowserPreference.default_: 'default', +}; + +const _$LegacyAppMarkMessagesReadOnScrollEnumMap = { + LegacyAppMarkMessagesReadOnScroll.always: 'always', + LegacyAppMarkMessagesReadOnScroll.never: 'never', + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly: + 'conversation-views-only', +}; + +LegacyAppAccount _$LegacyAppAccountFromJson(Map json) => + LegacyAppAccount( + realm: const _LegacyAppUrlJsonConverter().fromJson( + json['realm'] as Map, + ), + apiKey: json['apiKey'] as String, + email: json['email'] as String, + userId: (json['userId'] as num?)?.toInt(), + zulipVersion: _$JsonConverterFromJson, String>( + json['zulipVersion'], + const _LegacyAppZulipVersionJsonConverter().fromJson, + ), + zulipFeatureLevel: (json['zulipFeatureLevel'] as num?)?.toInt(), + ackedPushToken: json['ackedPushToken'] as String?, + ); + +Map _$LegacyAppAccountToJson(LegacyAppAccount instance) => + { + 'realm': const _LegacyAppUrlJsonConverter().toJson(instance.realm), + 'apiKey': instance.apiKey, + 'email': instance.email, + 'userId': instance.userId, + 'zulipVersion': _$JsonConverterToJson, String>( + instance.zulipVersion, + const _LegacyAppZulipVersionJsonConverter().toJson, + ), + 'zulipFeatureLevel': instance.zulipFeatureLevel, + 'ackedPushToken': instance.ackedPushToken, + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); diff --git a/lib/model/message.dart b/lib/model/message.dart index fd5de1adbd..9e9e45ca6a 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,9 +1,16 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +import '../api/exception.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'; @@ -14,16 +21,30 @@ 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); void unregisterMessageList(MessageListView view); + void markReadFromScroll(Iterable messageIds); + Future sendMessage({ required MessageDestination destination, 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. /// @@ -35,17 +56,70 @@ mixin MessageStore { /// All [Message] objects in the resulting list will be present in /// [this.messages]. void reconcileMessages(List messages); + + /// Whether the current edit request for the given message, if any, has failed. + /// + /// Will be null if there is no current edit request. + /// Will be false if the current request hasn't failed + /// and the update-message event hasn't arrived. + bool? getEditMessageErrorStatus(int messageId); + + /// Edit a message's content, via a request to the server. + /// + /// Should only be called when there is no current edit request for [messageId], + /// i.e., [getEditMessageErrorStatus] returns null for [messageId]. + /// + /// See also: + /// * [getEditMessageErrorStatus] + /// * [takeFailedMessageEdit] + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }); + + /// Forgets the failed edit request and returns the attempted new content. + /// + /// Should only be called when there is a failed request, + /// per [getEditMessageErrorStatus]. + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); +} + +class _EditMessageRequestStatus { + _EditMessageRequestStatus({ + required this.hasError, + required this.originalRawContent, + required this.newContent, + }); + + bool hasError; + final String originalRawContent; + 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 @@ -53,12 +127,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); } @@ -81,6 +159,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 @@ -96,21 +177,92 @@ 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; + } + + static const _markReadOnScrollBatchSize = 1000; + static const _markReadOnScrollDebounceDuration = Duration(milliseconds: 500); + final _markReadOnScrollQueue = _MarkReadOnScrollQueue(); + bool _markReadOnScrollBusy = false; + + /// Returns true on success, false on failure. + Future _sendMarkReadOnScrollRequest(List toSend) async { + assert(toSend.isNotEmpty); + + // TODO(#1581) mark as read locally for latency compensation + // (in Unreads and on the message objects) + try { + await updateMessageFlags(connection, + messages: toSend, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + } on ApiRequestException { + // TODO(#1581) un-mark as read locally? + return false; + } + return true; + } + + @override + void markReadFromScroll(Iterable messageIds) async { + assert(!_disposed); + _markReadOnScrollQueue.addAll(messageIds); + if (_markReadOnScrollBusy) return; + + _markReadOnScrollBusy = true; + try { + do { + final toSend = []; + int numFromQueue = 0; + for (final messageId in _markReadOnScrollQueue.iterable) { + if (toSend.length == _markReadOnScrollBatchSize) { + break; + } + final message = messages[messageId]; + if (message != null && !message.flags.contains(MessageFlag.read)) { + toSend.add(message.id); + } + numFromQueue++; + } + + if (toSend.isEmpty || await _sendMarkReadOnScrollRequest(toSend)) { + if (_disposed) return; + _markReadOnScrollQueue.removeFirstN(numFromQueue); + } + if (_disposed) return; + + await Future.delayed(_markReadOnScrollDebounceDuration); + if (_disposed) return; + } while (_markReadOnScrollQueue.isNotEmpty); + } finally { + if (!_disposed) { + _markReadOnScrollBusy = false; + } + } } @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. @@ -132,6 +284,67 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + @override + bool? getEditMessageErrorStatus(int messageId) => + _editMessageRequests[messageId]?.hasError; + + final Map _editMessageRequests = {}; + + @override + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) async { + assert(!_disposed); + if (_editMessageRequests.containsKey(messageId)) { + throw StateError('an edit request is already in progress'); + } + + _editMessageRequests[messageId] = _EditMessageRequestStatus( + hasError: false, originalRawContent: originalRawContent, newContent: newContent); + _notifyMessageListViewsForOneMessage(messageId); + try { + await updateMessage(connection, + messageId: messageId, + content: newContent, + prevContentSha256: sha256.convert(utf8.encode(originalRawContent)).toString()); + // On success, we'll clear the status from _editMessageRequests + // when we get the event. + } 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 + // (can happen with network issues). + // Or, the message was deleted. + return; + } + status.hasError = true; + _notifyMessageListViewsForOneMessage(messageId); + } + } + + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); + final status = _editMessageRequests.remove(messageId); + _notifyMessageListViewsForOneMessage(messageId); + if (status == null) { + throw StateError('called takeFailedMessageEdit, but no edit'); + } + if (!status.hasError) { + throw StateError("called takeFailedMessageEdit, but edit hasn't failed"); + } + return ( + originalRawContent: status.originalRawContent, + newContent: status.newContent + ); + } + void handleUserTopicEvent(UserTopicEvent event) { for (final view in _messageListViews) { view.handleUserTopicEvent(event); @@ -144,6 +357,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); } @@ -183,6 +398,12 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // The message is guaranteed to be edited. // See also: https://zulip.com/api/get-events#update_message message.editState = MessageEditState.edited; + + // Clear the edit-message progress feedback. + // This makes a rare bug where we might clear the feedback too early, + // if the user raced with themself to edit the same message + // from multiple clients. + _editMessageRequests.remove(message.id); } if (event.renderedContent != null) { assert(message.contentType == 'text/html', @@ -237,6 +458,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + // TODO predict outbox message moves using propagateMode + for (final view in _messageListViews) { view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds); } @@ -245,6 +468,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { void handleDeleteMessageEvent(DeleteMessageEvent event) { for (final messageId in event.messageIds) { messages.remove(messageId); + _editMessageRequests.remove(messageId); } for (final view in _messageListViews) { view.handleDeleteMessageEvent(event); @@ -330,4 +554,441 @@ 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; + } +} + +class _MarkReadOnScrollQueue { + _MarkReadOnScrollQueue(); + + bool get isNotEmpty => _queue.isNotEmpty; + + final _set = {}; + final _queue = QueueList(); + + /// Add [messageIds] to the end of the queue, + /// if they aren't already in the queue. + void addAll(Iterable messageIds) { + for (final messageId in messageIds) { + if (_set.add(messageId)) { + _queue.add(messageId); + } + } + } + + Iterable get iterable => _queue; + + void removeFirstN(int n) { + for (int i = 0; i < n; i++) { + if (_queue.isEmpty) break; + _set.remove(_queue.removeFirst()); + } + } +} + +/// 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 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 58a0e1bb95..475622f025 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,9 +10,12 @@ import '../api/route/messages.dart'; import 'algorithms.dart'; import 'channel.dart'; import 'content.dart'; +import 'message.dart'; import 'narrow.dart'; import 'store.dart'; +export '../api/route/messages.dart' show Anchor, AnchorCode, NumericAnchor; + /// The number of messages to fetch in each request. const kMessageListFetchBatchSize = 100; // TODO tune @@ -35,88 +38,145 @@ class MessageListDateSeparatorItem extends MessageListItem { MessageListDateSeparatorItem(this.message); } -/// A message to show in the message list. -class MessageListMessageItem extends MessageListItem { - final Message message; - ZulipMessageContent content; +/// A [MessageBase] to show in the message list. +sealed class MessageListMessageBaseItem extends MessageListItem { + MessageBase get message; + ZulipMessageContent get content; bool showSender; bool isLastInBlock; - MessageListMessageItem( - this.message, - this.content, { + MessageListMessageBaseItem({ required this.showSender, required this.isLastInBlock, }); } -/// Indicates the app is loading more messages at the top. -// TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer] -class MessageListLoadingItem extends MessageListItem { - final MessageListDirection direction; +/// A [Message] to show in the message list. +class MessageListMessageItem extends MessageListMessageBaseItem { + @override + final Message message; + @override + ZulipMessageContent content; + + MessageListMessageItem( + this.message, + this.content, { + required super.showSender, + required super.isLastInBlock, + }); +} - const MessageListLoadingItem(this.direction); +/// An [OutboxMessage] to show in the message list. +class MessageListOutboxMessageItem extends MessageListMessageBaseItem { + @override + final OutboxMessage message; + @override + final ZulipContent content; + + MessageListOutboxMessageItem( + this.message, { + required super.showSender, + required super.isLastInBlock, + }) : content = ZulipContent(nodes: [ + ParagraphNode(links: null, nodes: [TextNode(message.contentMarkdown)]), + ]); } -enum MessageListDirection { older } +/// The status of outstanding or recent fetch requests from a [MessageListView]. +enum FetchingStatus { + /// The model has not made any fetch requests (since its last reset, if any). + unstarted, + + /// The model has made a `fetchInitial` request, which hasn't succeeded. + fetchInitial, + + /// The model made a successful `fetchInitial` request, + /// and has no outstanding requests or backoff. + idle, -/// Indicates we've reached the oldest message in the narrow. -class MessageListHistoryStartItem extends MessageListItem { - const MessageListHistoryStartItem(); + /// The model has an active `fetchOlder` or `fetchNewer` request. + fetchingMore, + + /// The model is in a backoff period from a failed request. + backoff, } /// The sequence of messages in a message list, and how to display them. /// /// This comprises much of the guts of [MessageListView]. mixin _MessageSequence { + /// Whether each message should have its own recipient header, + /// even if it's in the same conversation as the previous message. + /// + /// In some message-list views, notably "Mentions" and "Starred", + /// it would be misleading to give the impression that consecutive messages + /// in the same conversation were sent one after the other + /// with no other messages in between. + /// By giving each message its own recipient header (a `true` value for this), + /// we intend to avoid giving that impression. + @visibleForTesting + bool get oneMessagePerBlock; + /// A sequence number for invalidating stale fetches. int generation = 0; - /// The messages. + /// The known messages in the list. + /// + /// This may or may not represent all the message history that + /// conceptually belongs in this message list. + /// That information is expressed in [fetched], [haveOldest], [haveNewest]. + /// + /// See also [middleMessage], an index which divides this list + /// into a top slice and a bottom slice. /// /// See also [contents] and [items]. final List messages = []; + /// An index into [messages] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleMessage] are the top slice of [messages], + /// and the indices from [middleMessage] to the end are the bottom slice. + /// + /// The corresponding item index is [middleItem]. + int middleMessage = 0; + /// Whether [messages] and [items] represent the results of a fetch. /// /// This allows the UI to distinguish "still working on fetching messages" /// from "there are in fact no messages here". - bool get fetched => _fetched; - bool _fetched = false; + bool get fetched => switch (_status) { + FetchingStatus.unstarted || FetchingStatus.fetchInitial => false, + _ => true, + }; /// Whether we know we have the oldest messages for this narrow. /// - /// (Currently we always have the newest messages for the narrow, - /// once [fetched] is true, because we start from the newest.) + /// See also [haveNewest]. bool get haveOldest => _haveOldest; bool _haveOldest = false; - /// Whether we are currently fetching the next batch of older messages. + /// Whether we know we have the newest messages for this narrow. /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field helps us avoid spamming the same request just to get - /// the same response each time. - /// - /// See also [fetchOlderCoolingDown]. - bool get fetchingOlder => _fetchingOlder; - bool _fetchingOlder = false; + /// See also [haveOldest]. + bool get haveNewest => _haveNewest; + bool _haveNewest = false; - /// Whether [fetchOlder] had a request error recently. - /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field mitigates spamming the same request and getting - /// the same error each time. + /// Whether this message list is currently busy when it comes to + /// fetching more messages. /// - /// "Recently" is decided by a [BackoffMachine] that resets - /// when a [fetchOlder] request succeeds. - /// - /// See also [fetchingOlder]. - bool get fetchOlderCoolingDown => _fetchOlderCoolingDown; - bool _fetchOlderCoolingDown = false; + /// Here "busy" means a new call to fetch more messages would do nothing, + /// rather than make any request to the server, + /// as a result of an existing recent request. + /// This is true both when the recent request is still outstanding, + /// and when it failed and the backoff from that is still in progress. + bool get busyFetchingMore => switch (_status) { + FetchingStatus.fetchingMore || FetchingStatus.backoff => true, + _ => false, + }; + + FetchingStatus _status = FetchingStatus.unstarted; - BackoffMachine? _fetchOlderCooldownBackoffMachine; + BackoffMachine? _fetchBackoffMachine; /// The parsed message contents, as a list parallel to [messages]. /// @@ -126,17 +186,45 @@ mixin _MessageSequence { /// It exists as an optimization, to memoize the work of parsing. final List contents = []; + /// The [OutboxMessage]s sent by the self-user, retrieved from + /// [MessageStore.outboxMessages]. + /// + /// See also [items]. + /// + /// O(N) iterations through this list are acceptable + /// because it won't normally have more than a few items. + final List outboxMessages = []; + /// The messages and their siblings in the UI, in order. /// /// This has a [MessageListMessageItem] corresponding to each element /// of [messages], in order. It may have additional items interspersed - /// before, between, or after the messages. + /// before, between, or after the messages. Then, similarly, + /// [MessageListOutboxMessageItem]s corresponding to [outboxMessages]. /// - /// This information is completely derived from [messages] and - /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. + /// This information is completely derived from [messages], [outboxMessages], + /// and the flags [haveOldest], [haveNewest], and [busyFetchingMore]. /// It exists as an optimization, to memoize that computation. + /// + /// See also [middleItem], an index which divides this list + /// into a top slice and a bottom slice. final QueueList items = QueueList(); + /// An index into [items] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleItem] are the top slice of [items], + /// and the indices from [middleItem] to the end are the bottom slice. + /// + /// The top slice of [items] corresponds to the top slice of [messages]. + /// The bottom slice of [items] corresponds to the bottom slice of [messages] + /// plus any [outboxMessages]. + /// + /// The bottom slice will either be empty + /// or start with a [MessageListMessageBaseItem]. + /// It will not start with a [MessageListDateSeparatorItem] + /// or a [MessageListRecipientHeaderItem]. + int middleItem = 0; + int _findMessageWithId(int messageId) { return binarySearchByKey(messages, messageId, (message, messageId) => message.id.compareTo(messageId)); @@ -146,18 +234,25 @@ mixin _MessageSequence { return binarySearchByKey(items, messageId, _compareItemToMessageId); } + Iterable? getMessagesRange(int firstMessageId, int lastMessageId) { + assert(firstMessageId <= lastMessageId); + final firstIndex = _findMessageWithId(firstMessageId); + final lastIndex = _findMessageWithId(lastMessageId); + if (firstIndex == -1 || lastIndex == -1) { + // TODO(log) + return null; + } + return messages.getRange(firstIndex, lastIndex + 1); + } + static int _compareItemToMessageId(MessageListItem item, int messageId) { switch (item) { - case MessageListHistoryStartItem(): return -1; - case MessageListLoadingItem(): - switch (item.direction) { - case MessageListDirection.older: return -1; - } case MessageListRecipientHeaderItem(:var message): case MessageListDateSeparatorItem(:var message): - if (message.id == null) return 1; // TODO(#1441): test + if (message.id == null) return 1; return message.id! <= messageId ? -1 : 1; case MessageListMessageItem(:var message): return message.id.compareTo(messageId); + case MessageListOutboxMessageItem(): return 1; } } @@ -209,6 +304,7 @@ mixin _MessageSequence { candidate++; assert(contents.length == messages.length); while (candidate < messages.length) { + if (candidate == middleMessage) middleMessage = target; if (test(messages[candidate])) { candidate++; continue; @@ -217,6 +313,7 @@ mixin _MessageSequence { contents[target] = contents[candidate]; target++; candidate++; } + if (candidate == middleMessage) middleMessage = target; messages.length = target; contents.length = target; assert(contents.length == messages.length); @@ -239,6 +336,13 @@ mixin _MessageSequence { } if (messagesToRemoveById.isEmpty) return false; + if (middleMessage == messages.length) { + middleMessage -= messagesToRemoveById.length; + } else { + final middleMessageId = messages[middleMessage].id; + middleMessage -= messagesToRemoveById + .where((id) => id < middleMessageId).length; + } assert(contents.length == messages.length); messages.removeWhere((message) => messagesToRemoveById.contains(message.id)); contents.removeWhere((content) => contentToRemove.contains(content)); @@ -253,25 +357,67 @@ mixin _MessageSequence { // On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages. // (Before that, ~2-5ms in jsonDecode and 0ms in fromJson, // so skip worrying about those steps.) + final oldLength = messages.length; assert(contents.length == messages.length); messages.insertAll(index, toInsert); contents.insertAll(index, toInsert.map( (message) => _parseMessageContent(message))); assert(contents.length == messages.length); + if (index <= middleMessage) { + middleMessage += messages.length - oldLength; + } _reprocessAll(); } + /// Append [outboxMessage] to [outboxMessages] and update derived data + /// accordingly. + /// + /// The caller is responsible for ensuring this is an appropriate thing to do + /// given [narrow] and other concerns. + void _addOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + assert(!outboxMessages.contains(outboxMessage)); + outboxMessages.add(outboxMessage); + _processOutboxMessage(outboxMessages.length - 1); + } + + /// Remove the [outboxMessage] from the view. + /// + /// Returns true if the outbox message was removed, false otherwise. + bool _removeOutboxMessage(OutboxMessage outboxMessage) { + if (!outboxMessages.remove(outboxMessage)) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + + /// Remove all outbox messages that satisfy [test] from [outboxMessages]. + /// + /// Returns true if any outbox messages were removed, false otherwise. + bool _removeOutboxMessagesWhere(bool Function(OutboxMessage) test) { + final count = outboxMessages.length; + outboxMessages.removeWhere(test); + if (outboxMessages.length == count) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + /// Reset all [_MessageSequence] data, and cancel any active fetches. void _reset() { generation += 1; messages.clear(); - _fetched = false; + middleMessage = 0; + outboxMessages.clear(); _haveOldest = false; - _fetchingOlder = false; - _fetchOlderCoolingDown = false; - _fetchOlderCooldownBackoffMachine = null; + _haveNewest = false; + _status = FetchingStatus.unstarted; + _fetchBackoffMachine = null; contents.clear(); items.clear(); + middleItem = 0; } /// Redo all computations from scratch, based on [messages]. @@ -283,24 +429,35 @@ 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 + || oneMessagePerBlock + || !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; @@ -308,73 +465,107 @@ mixin _MessageSequence { items.add(MessageListDateSeparatorItem(message)); canShareSender = false; } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + canShareSender = prevMessageItem.message.senderId == message.senderId; } } - 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) { + middleItem = items.length; + } + items.add(item); } - /// Update [items] to include markers at start and end as appropriate. - void _updateEndMarkers() { - assert(fetched); - assert(!(fetchingOlder && fetchOlderCoolingDown)); - final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown; - assert(!(effectiveFetchingOlder && haveOldest)); - final startMarker = switch ((effectiveFetchingOlder, haveOldest)) { - (true, _) => const MessageListLoadingItem(MessageListDirection.older), - (_, true) => const MessageListHistoryStartItem(), - (_, _) => null, - }; - final hasStartMarker = switch (items.firstOrNull) { - MessageListLoadingItem() => true, - MessageListHistoryStartItem() => true, - _ => false, - }; - switch ((startMarker != null, hasStartMarker)) { - case (true, true): items[0] = startMarker!; - case (true, _ ): items.addFirst(startMarker!); - case (_, true): items.removeFirst(); - case (_, _ ): break; - } - } - - /// Recompute [items] from scratch, based on [messages], [contents], and flags. + /// 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) { + assert(items.lastOrNull is! MessageListOutboxMessageItem); + 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)); + } + + /// Append to [items] based on the index-th message in [outboxMessages]. + /// + /// All [messages] and previous messages in [outboxMessages] must already have + /// been processed. + void _processOutboxMessage(int index) { + final prevMessage = index == 0 ? messages.lastOrNull + : outboxMessages[index - 1]; + final message = outboxMessages[index]; + + _addItemsForMessage(message, + // The first outbox message item becomes the middle item + // when the bottom slice of [messages] is empty. + shouldSetMiddleItem: index == 0 && middleMessage == messages.length, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListOutboxMessageItem( + message, showSender: !canShareSender, isLastInBlock: true)); + } + + /// Remove items associated with [outboxMessages] from [items]. + /// + /// This is designed to be idempotent; repeated calls will not change the + /// content of [items]. + /// + /// This is efficient due to the expected small size of [outboxMessages]. + void _removeOutboxMessageItems() { + // This loop relies on the assumption that all items that follow + // the last [MessageListMessageItem] are derived from outbox messages. + while (items.isNotEmpty && items.last is! MessageListMessageItem) { + items.removeLast(); + } + + if (items.isNotEmpty) { + final lastItem = items.last as MessageListMessageItem; + lastItem.isLastInBlock = true; + } + if (middleMessage == messages.length) middleItem = items.length; + } + + /// Recompute the portion of [items] derived from outbox messages, + /// based on [outboxMessages] and [messages]. + /// + /// All [messages] should have been processed when this is called. + void _reprocessOutboxMessages() { + assert(haveNewest); + _removeOutboxMessageItems(); + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } + } + + /// Recompute [items] from scratch, based on [messages], [contents], + /// [outboxMessages] and flags. void _reprocessAll() { items.clear(); for (var i = 0; i < messages.length; i++) { _processMessage(i); } - _updateEndMarkers(); + if (middleMessage == messages.length) middleItem = items.length; + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } } } @visibleForTesting -bool haveSameRecipient(Message prevMessage, Message message) { - if (prevMessage is StreamMessage && message is StreamMessage) { - if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; - } else if (prevMessage is DmMessage && message is DmMessage) { - if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { - return false; - } - } else { - return false; - } - return true; - - // switch ((prevMessage, message)) { - // case (StreamMessage(), StreamMessage()): - // // TODO(dart-3): this doesn't type-narrow prevMessage and message - // case (DmMessage(), DmMessage()): - // // … - // default: - // return false; - // } +bool haveSameRecipient(MessageBase prevMessage, MessageBase message) { + return prevMessage.conversation.isSameAs(message.conversation); } @visibleForTesting -bool messagesSameDay(Message prevMessage, Message message) { +bool messagesSameDay(MessageBase prevMessage, MessageBase message) { // TODO memoize [DateTime]s... also use memoized for showing date/time in msglist final prevTime = DateTime.fromMillisecondsSinceEpoch(prevMessage.timestamp * 1000); final time = DateTime.fromMillisecondsSinceEpoch(message.timestamp * 1000); @@ -382,16 +573,6 @@ bool messagesSameDay(Message prevMessage, Message message) { return true; } -// Intended for [Message.allRecipientIds]. Assumes efficient `length`. -bool _equalIdSequences(Iterable xs, Iterable ys) { - if (xs.length != ys.length) return false; - final xs_ = xs.iterator; final ys_ = ys.iterator; - while (xs_.moveNext() && ys_.moveNext()) { - if (xs_.current != ys_.current) return false; - } - return true; -} - bool _sameDay(DateTime date1, DateTime date2) { if (date1.year != date2.year) return false; if (date1.month != date2.month) return false; @@ -414,13 +595,51 @@ bool _sameDay(DateTime date1, DateTime date2) { /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. class MessageListView with ChangeNotifier, _MessageSequence { - MessageListView._({required this.store, required this.narrow}); + factory MessageListView.init({ + required PerAccountStore store, + required Narrow narrow, + required Anchor anchor, + }) { + return MessageListView._(store: store, narrow: narrow, anchor: anchor) + .._register(); + } + + MessageListView._({ + required this.store, + required Narrow narrow, + required Anchor anchor, + }) : _narrow = narrow, _anchor = anchor; + + final PerAccountStore store; - factory MessageListView.init( - {required PerAccountStore store, required Narrow narrow}) { - final view = MessageListView._(store: store, narrow: narrow); - store.registerMessageList(view); - return view; + /// The narrow shown in this message list. + /// + /// This can change over time, notably if showing a topic that gets moved, + /// or if [renarrowAndFetch] is called. + Narrow get narrow => _narrow; + Narrow _narrow; + + /// Set [narrow] to [newNarrow], reset, [notifyListeners], and [fetchInitial]. + void renarrowAndFetch(Narrow newNarrow) { + _narrow = newNarrow; + _reset(); + notifyListeners(); + fetchInitial(); + } + + /// The anchor point this message list starts from in the message history. + /// + /// This is passed to the server in the get-messages request + /// sent by [fetchInitial]. + /// That includes not only the original [fetchInitial] call made by + /// the message-list widget, but any additional [fetchInitial] calls + /// which might be made internally by this class in order to + /// fetch the messages from scratch, e.g. after certain events. + Anchor get anchor => _anchor; + Anchor _anchor; + + void _register() { + store.registerMessageList(this); } @override @@ -429,8 +648,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { super.dispose(); } - final PerAccountStore store; - Narrow narrow; + @override bool get oneMessagePerBlock => switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => true, + }; /// Whether [message] should actually appear in this message list, /// given that it does belong to the narrow. @@ -439,24 +665,26 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// one way or another. /// /// See also [_allMessagesVisible]. - bool _messageVisible(Message message) { + bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message) { - StreamMessage() => - store.isTopicVisible(message.streamId, message.topic), - DmMessage() => true, + return switch (message.conversation) { + StreamConversation(:final streamId, :final topic) => + store.isTopicVisible(streamId, topic), + DmConversation() => true, }; case ChannelNarrow(:final streamId): - assert(message is StreamMessage && message.streamId == streamId); - if (message is! StreamMessage) return false; - return store.isTopicVisibleInStream(streamId, message.topic); + assert(message is MessageBase + && message.conversation.streamId == streamId); + if (message is! MessageBase) return false; + return store.isTopicVisibleInStream(streamId, message.conversation.topic); case TopicNarrow(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return true; } } @@ -476,6 +704,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return VisibilityEffect.none; } } @@ -493,37 +722,73 @@ class MessageListView with ChangeNotifier, _MessageSequence { case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return true; } } + void _setStatus(FetchingStatus value, {FetchingStatus? was}) { + assert(was == null || _status == was); + _status = value; + if (!fetched) return; + notifyListeners(); + } + /// Fetch messages, starting from scratch. Future fetchInitial() async { - // TODO(#80): fetch from anchor firstUnread, instead of newest - // TODO(#82): fetch from a given message ID as anchor - assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); + assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); + + if (narrow case KeywordSearchNarrow(keyword: '')) { + // The server would reject an empty keyword search; skip the request. + // TODO this seems like an awkward layer to handle this at -- + // probably better if the UI code doesn't take it to this point. + _haveOldest = true; + _haveNewest = true; + _setStatus(FetchingStatus.idle, was: FetchingStatus.unstarted); + return; + } + + _setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted); // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: AnchorCode.newest, + anchor: anchor, numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, ); if (this.generation > generation) return; + _adjustNarrowForTopicPermalink(result.messages.firstOrNull); + store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) + + // The bottom slice will start at the "anchor message". + // This is the first visible message at or past [anchor] if any, + // else the last visible message if any. [reachedAnchor] helps track that. + bool reachedAnchor = false; for (final message in result.messages) { - if (_messageVisible(message)) { - _addMessage(message); + if (!_messageVisible(message)) continue; + if (!reachedAnchor) { + // Push the previous message into the top slice. + middleMessage = messages.length; + // We could interpret [anchor] for ourselves; but the server has already + // done that work, reducing it to an int, `result.anchor`. So use that. + reachedAnchor = message.id >= result.anchor; } + _addMessage(message); } - _fetched = true; _haveOldest = result.foundOldest; - _updateEndMarkers(); - notifyListeners(); + _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } + + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); } /// Update [narrow] for the result of a "with" narrow (topic permalink) fetch. @@ -550,27 +815,100 @@ class MessageListView with ChangeNotifier, _MessageSequence { // This can't be a redirect; a redirect can't produce an empty result. // (The server only redirects if the message is accessible to the user, // and if it is, it'll appear in the result, making it non-empty.) - this.narrow = narrow.sansWith(); + _narrow = narrow.sansWith(); case StreamMessage(): - this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); + _narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); case DmMessage(): // TODO(log) assert(false); } } /// Fetch the next batch of older messages, if applicable. + /// + /// If there are no older messages to fetch (i.e. if [haveOldest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. Future fetchOlder() async { if (haveOldest) return; - if (fetchingOlder) return; - if (fetchOlderCoolingDown) return; + if (busyFetchingMore) return; assert(fetched); + assert(messages.isNotEmpty); + await _fetchMore( + anchor: NumericAnchor(messages[0].id), + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.last.id == messages[0].id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeLast(); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + final fetchedMessages = _allMessagesVisible + ? result.messages // Avoid unnecessarily copying the list. + : result.messages.where(_messageVisible); + + _insertAllMessages(0, fetchedMessages); + _haveOldest = result.foundOldest; + }); + } + + /// Fetch the next batch of newer messages, if applicable. + /// + /// If there are no newer messages to fetch (i.e. if [haveNewest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. + Future fetchNewer() async { + if (haveNewest) return; + if (busyFetchingMore) return; + assert(fetched); + assert(messages.isNotEmpty); + await _fetchMore( + anchor: NumericAnchor(messages.last.id), + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.first.id == messages.last.id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeAt(0); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + for (final message in result.messages) { + if (_messageVisible(message)) { + _addMessage(message); + } + } + _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } + }); + } + + Future _fetchMore({ + required Anchor anchor, + required int numBefore, + required int numAfter, + required void Function(GetMessagesResult) processResult, + }) async { assert(narrow is! TopicNarrow // We only intend to send "with" in [fetchInitial]; see there. || (narrow as TopicNarrow).with_ == null); - assert(messages.isNotEmpty); - _fetchingOlder = true; - _updateEndMarkers(); - notifyListeners(); + _setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle); final generation = this.generation; bool hasFetchError = false; try { @@ -578,10 +916,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { try { result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: NumericAnchor(messages[0].id), + anchor: anchor, includeAnchor: false, - numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numBefore: numBefore, + numAfter: numAfter, + allowEmptyTopicName: true, ); } catch (e) { hasFetchError = true; @@ -589,53 +928,104 @@ class MessageListView with ChangeNotifier, _MessageSequence { } if (this.generation > generation) return; - if (result.messages.isNotEmpty - && result.messages.last.id == messages[0].id) { - // TODO(server-6): includeAnchor should make this impossible - result.messages.removeLast(); - } - - store.reconcileMessages(result.messages); - store.recentSenders.handleMessages(result.messages); // TODO(#824) - - final fetchedMessages = _allMessagesVisible - ? result.messages // Avoid unnecessarily copying the list. - : result.messages.where(_messageVisible); - - _insertAllMessages(0, fetchedMessages); - _haveOldest = result.foundOldest; + processResult(result); } finally { if (this.generation == generation) { - _fetchingOlder = false; if (hasFetchError) { - assert(!fetchOlderCoolingDown); - _fetchOlderCoolingDown = true; - unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine()) + _setStatus(FetchingStatus.backoff, was: FetchingStatus.fetchingMore); + unawaited((_fetchBackoffMachine ??= BackoffMachine()) .wait().then((_) { if (this.generation != generation) return; - _fetchOlderCoolingDown = false; - _updateEndMarkers(); - notifyListeners(); + _setStatus(FetchingStatus.idle, was: FetchingStatus.backoff); })); } else { - _fetchOlderCooldownBackoffMachine = null; + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchingMore); + _fetchBackoffMachine = null; } - _updateEndMarkers(); - notifyListeners(); } } } + /// Reset this view to start from the newest messages. + /// + /// This will set [anchor] to [AnchorCode.newest], + /// and cause messages to be re-fetched from scratch. + void jumpToEnd() { + assert(fetched); + assert(!haveNewest); + assert(anchor != AnchorCode.newest); + _anchor = AnchorCode.newest; + _reset(); + notifyListeners(); + fetchInitial(); + } + + bool _shouldAddOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + return !outboxMessage.hidden + && narrow.containsMessage(outboxMessage) == true + && _messageVisible(outboxMessage); + } + + /// Reads [MessageStore.outboxMessages] and copies to [outboxMessages] + /// the ones belonging to this view. + /// + /// This should only be called when [haveNewest] is true + /// because outbox messages are considered newer than regular messages. + /// + /// This does not call [notifyListeners]. + void _syncOutboxMessagesFromStore() { + assert(haveNewest); + assert(outboxMessages.isEmpty); + for (final outboxMessage in store.outboxMessages.values) { + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + } + } + } + + /// Add [outboxMessage] if it belongs to the view. + void addOutboxMessage(OutboxMessage outboxMessage) { + // We don't have the newest messages; + // we shouldn't show any outbox messages until we do. + if (!haveNewest) return; + + assert(outboxMessages.none( + (message) => message.localMessageId == outboxMessage.localMessageId)); + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + notifyListeners(); + } + } + + /// 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) { + if (_removeOutboxMessage(outboxMessage)) { + notifyListeners(); + } + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { case VisibilityEffect.none: return; case VisibilityEffect.muted: - if (_removeMessagesWhere((message) => - (message is StreamMessage - && message.streamId == event.streamId - && message.topic == event.topicName))) { + bool removed = _removeMessagesWhere((message) => + message is StreamMessage + && message.streamId == event.streamId + && message.topic == event.topicName); + + removed |= _removeOutboxMessagesWhere((message) => + message is StreamOutboxMessage + && message.conversation.streamId == event.streamId + && message.conversation.topic == event.topicName); + + if (removed) { notifyListeners(); } @@ -660,15 +1050,37 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// Add [MessageEvent.message] to this view, if it belongs here. void handleMessageEvent(MessageEvent event) { final message = event.message; - if (!narrow.containsMessage(message) || !_messageVisible(message)) { + if (narrow.containsMessage(message) != true || !_messageVisible(message)) { + assert(event.localMessageId == null || outboxMessages.none((message) => + message.localMessageId == int.parse(event.localMessageId!, radix: 10))); return; } - if (!_fetched) { - // TODO mitigate this fetch/event race: save message to add to list later + if (!haveNewest) { + // This message list's [messages] doesn't yet reach the new end + // of the narrow's message history. (Either [fetchInitial] hasn't yet + // completed, or if it has then it was in the middle of history and no + // subsequent [fetchNewer] has reached the end.) + // So this still-newer message doesn't belong. + // Leave it to be found by a subsequent fetch when appropriate. + // TODO mitigate this fetch/event race: save message to add to list later, + // in case the fetch that reaches the end is already ongoing and + // didn't include this message. return; } - // TODO insert in middle instead, when appropriate + + // Remove the outbox messages temporarily. + // We'll add them back after the new message. + _removeOutboxMessageItems(); + // TODO insert in middle of [messages] instead, when appropriate _addMessage(message); + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // [outboxMessages] is expected to be short, so removing the corresponding + // outbox message and reprocessing them all in linear time is efficient. + outboxMessages.removeWhere( + (message) => message.localMessageId == localMessageId); + } + _reprocessOutboxMessages(); notifyListeners(); } @@ -713,9 +1125,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { switch (propagateMode) { case PropagateMode.changeAll: case PropagateMode.changeLater: - narrow = newNarrow; - _reset(); - fetchInitial(); + renarrowAndFetch(newNarrow); case PropagateMode.changeOne: } } @@ -736,12 +1146,19 @@ class MessageListView with ChangeNotifier, _MessageSequence { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - // The messages were and remain in this narrow. - // TODO(#421): … except they may have become muted or not. + // The messages didn't enter or leave this narrow. + // TODO(#1255): … except they may have become muted or not. // We'll handle that at the same time as we handle muting itself changing. // Recipient headers, and downstream of those, may change, though. _messagesMovedInternally(messageIds); + case KeywordSearchNarrow(): + // This might not be quite true, since matches can be determined by + // the topic alone, and topics change. Punt on trying to add/remove + // messages, though, because we aren't equipped to evaluate the match + // without asking the server. + _messagesMovedInternally(messageIds); + case ChannelNarrow(:final streamId): switch ((origStreamId == streamId, newStreamId == streamId)) { case (false, false): return; @@ -787,6 +1204,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + final isAnyPresent = + outboxMessages.any((message) => message.localMessageId == localMessageId); + if (isAnyPresent) { + notifyListeners(); + } + } + /// 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/narrow.dart b/lib/model/narrow.dart index 104334a956..f7ee187116 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -19,7 +19,10 @@ sealed class Narrow { /// This does not necessarily mean the message list would show this message /// when navigated to this narrow; in particular it does not address the /// question of whether the stream or topic, or the sending user, is muted. - bool containsMessage(MessageBase message); + /// + /// Null when the client is unable to predict whether the message + /// satisfies the filters of this narrow, e.g. when this is a search narrow. + bool? containsMessage(MessageBase message); /// This narrow, expressed as an [ApiNarrow]. ApiNarrow apiEncode(); @@ -365,3 +368,31 @@ class StarredMessagesNarrow extends Narrow { @override int get hashCode => 'StarredMessagesNarrow'.hashCode; } + +/// A keyword-search narrow. +/// +/// [keyword] must have been trimmed with [String.trim]. +class KeywordSearchNarrow extends Narrow { + KeywordSearchNarrow(this.keyword) + : assert(keyword.trim() == keyword); + + final String keyword; + + @override + bool? containsMessage(MessageBase message) => null; + + @override + ApiNarrow apiEncode() => [ApiNarrowSearch(keyword)]; + + @override + String toString() => 'KeywordSearchNarrow($keyword)'; + + @override + bool operator ==(Object other) { + if (other is! KeywordSearchNarrow) return false; + return other.keyword == keyword; + } + + @override + int get hashCode => Object.hash('KeywordSearchNarrow', keyword); +} diff --git a/lib/model/presence.dart b/lib/model/presence.dart new file mode 100644 index 0000000000..d21ece421a --- /dev/null +++ b/lib/model/presence.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import 'store.dart'; + +/// The model for tracking which users are online, idle, and offline. +/// +/// Use [presenceStatusForUser]. If that returns null, the user is offline. +/// +/// This substore is its own [ChangeNotifier], +/// so callers need to remember to add a listener (and remove it on dispose). +/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree +/// to updates. +class Presence extends PerAccountStoreBase with ChangeNotifier { + Presence({ + required super.core, + required this.serverPresencePingInterval, + required this.serverPresenceOfflineThresholdSeconds, + required this.realmPresenceDisabled, + required Map initial, + }) : _map = initial; + + final Duration serverPresencePingInterval; + final int serverPresenceOfflineThresholdSeconds; + // TODO(#668): update this realm setting (probably by accessing it from a new + // realm/server-settings substore that gets passed to Presence) + final bool realmPresenceDisabled; + + Map _map; + + AppLifecycleListener? _appLifecycleListener; + + void _handleLifecycleStateChange(AppLifecycleState newState) { + assert(!_disposed); // We remove the listener in [dispose]. + + // Since this handler can cause multiple requests within a + // serverPresencePingInterval period, we pass `pingOnly: true`, for now, because: + // - This makes the request cheap for the server. + // - We don't want to record stale presence data when responses arrive out + // of order. This handler would increase the risk of that by potentially + // sending requests more frequently than serverPresencePingInterval. + // (`pingOnly: true` causes presence data to be omitted in the response.) + // TODO(#1611) Both of these reasons can be easily addressed by passing + // lastUpdateId. Do that, and stop sending `pingOnly: true`. + // (For the latter point, we'd ignore responses with a stale lastUpdateId.) + _maybePingAndRecordResponse(newState, pingOnly: true); + } + + bool _hasStarted = false; + + void start() async { + if (!debugEnable) return; + if (_hasStarted) { + throw StateError('Presence.start should only be called once.'); + } + _hasStarted = true; + + _appLifecycleListener = AppLifecycleListener( + onStateChange: _handleLifecycleStateChange); + + _poll(); + } + + Future _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, { + required bool pingOnly, + }) async { + if (realmPresenceDisabled) return; + + final UpdatePresenceResult result; + switch (appLifecycleState) { + case null: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + // No presence update. + return; + case AppLifecycleState.detached: + // > The application is still hosted by a Flutter engine but is + // > detached from any host views. + // TODO see if this actually works as a way to send an "idle" update + // when the user closes the app completely. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.idle, + newUserInput: false); + case AppLifecycleState.resumed: + // > […] the default running mode for a running application that has + // > input focus and is visible. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: true); + case AppLifecycleState.inactive: + // > At least one view of the application is visible, but none have + // > input focus. The application is otherwise running normally. + // For example, we expect this state when the user is selecting a file + // to upload. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: false); + } + if (!pingOnly) { + _map = result.presences!; + notifyListeners(); + } + } + + void _poll() async { + assert(!_disposed); + while (true) { + // We put the wait upfront because we already have data when [start] is + // called; it comes from /register. + await Future.delayed(serverPresencePingInterval); + if (_disposed) return; + + await _maybePingAndRecordResponse( + SchedulerBinding.instance.lifecycleState, pingOnly: false); + if (_disposed) return; + } + } + + bool _disposed = false; + + @override + void dispose() { + _appLifecycleListener?.dispose(); + _disposed = true; + super.dispose(); + } + + /// The [PresenceStatus] for [userId], or null if the user is offline. + PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) { + final now = utcNow.millisecondsSinceEpoch ~/ 1000; + final perUserPresence = _map[userId]; + if (perUserPresence == null) return null; + final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence; + + if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) { + return PresenceStatus.active; + } else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) { + // The API doc is kind of confusing, but this seems correct: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431 + // TODO clarify that API doc + return PresenceStatus.idle; + } else { + return null; + } + } + + void handlePresenceEvent(PresenceEvent event) { + // TODO(#1618) + } + + /// In debug mode, controls whether presence requests are made. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnable { + bool result = true; + assert(() { + result = _debugEnable; + return true; + }()); + return result; + } + static bool _debugEnable = true; + static set debugEnable(bool value) { + assert(() { + _debugEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + debugEnable = true; + } +} diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart new file mode 100644 index 0000000000..59c8347591 --- /dev/null +++ b/lib/model/saved_snippet.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +mixin SavedSnippetStore { + Map get savedSnippets; +} + +class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore { + SavedSnippetStoreImpl({ + required super.core, + required Iterable savedSnippets, + }) : _savedSnippets = { + for (final savedSnippet in savedSnippets) + savedSnippet.id: savedSnippet, + }; + + @override + late Map savedSnippets = UnmodifiableMapView(_savedSnippets); + final Map _savedSnippets; + + void handleSavedSnippetsEvent(SavedSnippetsEvent event) { + switch (event) { + case SavedSnippetsAddEvent(:final savedSnippet): + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsUpdateEvent(:final savedSnippet): + assert(_savedSnippets[savedSnippet.id]!.dateCreated + == savedSnippet.dateCreated); // TODO(log) + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsRemoveEvent(:final savedSnippetId): + _savedSnippets.remove(savedSnippetId); + } + } +} diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index 9bfa74f627..782b9409e2 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -367,12 +367,243 @@ i1.GeneratedColumn _column_12(String aliasedName) => 'CHECK ("value" IN (0, 1))', ), ); + +final class Schema7 extends i0.VersionedSchema { + Schema7({required super.database}) : super(version: 7); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape4 globalSettings = Shape4( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema8 extends i0.VersionedSchema { + Schema8({required super.database}) : super(version: 8); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape5 globalSettings = Shape5( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema9 extends i0.VersionedSchema { + Schema9({required super.database}) : super(version: 9); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; + i1.GeneratedColumn get legacyUpgradeState => + columnsByName['legacy_upgrade_state']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -401,6 +632,21 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from5To6(migrator, schema); return 6; + case 6: + final schema = Schema7(database: database); + final migrator = i1.Migrator(database, schema); + await from6To7(migrator, schema); + return 7; + case 7: + final schema = Schema8(database: database); + final migrator = i1.Migrator(database, schema); + await from7To8(migrator, schema); + return 8; + case 8: + final schema = Schema9(database: database); + final migrator = i1.Migrator(database, schema); + await from8To9(migrator, schema); + return 9; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -413,6 +659,9 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -420,5 +669,8 @@ i1.OnUpgrade stepByStep({ from3To4: from3To4, from4To5: from4To5, from5To6: from5To6, + from6To7: from6To7, + from7To8: from7To8, + from8To9: from8To9, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 5fd2fec9f8..e8a309ac29 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../generated/l10n/zulip_localizations.dart'; import 'binding.dart'; import 'database.dart'; +import 'narrow.dart'; import 'store.dart'; /// The user's choice of visual theme for the app. @@ -45,6 +46,65 @@ enum BrowserPreference { external, } +/// The user's choice of when to open a message list at their first unread, +/// rather than at the newest message. +/// +/// This setting has no effect when navigating to a specific message: +/// in that case the message list opens at that message, +/// regardless of this setting. +enum VisitFirstUnreadSetting { + /// Always go to the first unread, rather than the newest message. + always, + + /// Go to the first unread in conversations, + /// and the newest in interleaved views. + conversations, + + /// Always go to the newest message, rather than the first unread. + never; + + /// The effective value of this setting if the user hasn't set it. + static VisitFirstUnreadSetting _default = conversations; +} + +/// The user's choice of which message-list views should +/// automatically mark messages as read when scrolling through them. +/// +/// This can be overridden by local state: for example, if you've just tapped +/// "Mark as unread from here" the view will stop marking as read automatically, +/// regardless of this setting. +enum MarkReadOnScrollSetting { + /// All views. + always, + + /// Only conversation views. + conversations, + + /// No views. + never; + + /// The effective value of this setting if the user hasn't set it. + static MarkReadOnScrollSetting _default = conversations; +} + +/// The outcome, or in-progress status, of migrating data from the legacy app. +enum LegacyUpgradeState { + /// It's not yet known whether there was data from the legacy app. + unknown, + + /// No legacy data was found. + noLegacy, + + /// Legacy data was found, but not yet migrated into this app's database. + found, + + /// Legacy data was found and migrated. + migrated, + ; + + static LegacyUpgradeState _default = unknown; +} + /// A general category of account-independent setting the user might set. /// /// Different kinds of settings call for different treatment in the UI, @@ -59,6 +119,9 @@ enum GlobalSettingType { /// we give it a placeholder value which isn't a real setting. placeholder, + /// Describes a pseudo-setting not directly exposed in the UI. + internal, + /// Describes a setting which enables an in-progress feature of the app. /// /// Sometimes when building a complex feature it's useful to merge PRs that @@ -110,6 +173,10 @@ enum BoolGlobalSetting { /// (Having one stable value in this enum is also handy for tests.) placeholderIgnore(GlobalSettingType.placeholder, false), + /// A pseudo-setting recording whether the user has been shown the + /// welcome dialog for upgrading from the legacy app. + upgradeWelcomeDialogShown(GlobalSettingType.internal, false), + /// An experimental flag to toggle rendering KaTeX content in messages. renderKatex(GlobalSettingType.experimentalFeatureFlag, false), @@ -119,7 +186,7 @@ enum BoolGlobalSetting { // Former settings which might exist in the database, // whose names should therefore not be reused: - // (this list is empty so far) + // openFirstUnread // v0.0.30 ; const BoolGlobalSetting(this.type, this.default_); @@ -228,6 +295,67 @@ class GlobalSettingsStore extends ChangeNotifier { } } + /// The user's choice of [VisitFirstUnreadSetting], applying our default. + /// + /// See also [shouldVisitFirstUnread] and [setVisitFirstUnread]. + VisitFirstUnreadSetting get visitFirstUnread { + return _data.visitFirstUnread ?? VisitFirstUnreadSetting._default; + } + + /// Set [visitFirstUnread], persistently for future runs of the app. + Future setVisitFirstUnread(VisitFirstUnreadSetting value) async { + await _update(GlobalSettingsCompanion(visitFirstUnread: Value(value))); + } + + /// The value that [visitFirstUnread] works out to for the given narrow. + bool shouldVisitFirstUnread({required Narrow narrow}) { + return switch (visitFirstUnread) { + VisitFirstUnreadSetting.always => true, + VisitFirstUnreadSetting.never => false, + VisitFirstUnreadSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + || KeywordSearchNarrow() + => false, + }, + }; + } + + /// The user's choice of [MarkReadOnScrollSetting], applying our default. + /// + /// See also [markReadOnScrollForNarrow] and [setMarkReadOnScroll]. + MarkReadOnScrollSetting get markReadOnScroll { + return _data.markReadOnScroll ?? MarkReadOnScrollSetting._default; + } + + /// Set [markReadOnScroll], persistently for future runs of the app. + Future setMarkReadOnScroll(MarkReadOnScrollSetting value) async { + await _update(GlobalSettingsCompanion(markReadOnScroll: Value(value))); + } + + /// The value that [markReadOnScroll] works out to for the given narrow. + bool markReadOnScrollForNarrow(Narrow narrow) { + return switch (markReadOnScroll) { + MarkReadOnScrollSetting.always => true, + MarkReadOnScrollSetting.never => false, + MarkReadOnScrollSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + || KeywordSearchNarrow() + => false, + }, + }; + } + + /// The outcome, or in-progress status, of migrating data from the legacy app. + LegacyUpgradeState get legacyUpgradeState { + return _data.legacyUpgradeState ?? LegacyUpgradeState._default; + } + /// The user's choice of the given bool-valued setting, or our default for it. /// /// See also [setBool]. diff --git a/lib/model/store.dart b/lib/model/store.dart index 939120113e..b3ce59206a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -26,9 +26,11 @@ import 'emoji.dart'; import 'localizations.dart'; import 'message.dart'; import 'message_list.dart'; +import 'presence.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; +import 'saved_snippet.dart'; import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; @@ -385,6 +387,12 @@ abstract class PerAccountStoreBase { /// This returns null if [reference] fails to parse as a URL. Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); + /// Always equal to `connection.zulipFeatureLevel` + /// and `account.zulipFeatureLevel`. + int get zulipFeatureLevel => connection.zulipFeatureLevel!; + + String get zulipVersion => account.zulipVersion; + //////////////////////////////// // Data attached to the self-account on the realm. @@ -425,7 +433,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, UserStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -467,9 +475,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, + serverPresencePingIntervalSeconds: initialSnapshot.serverPresencePingIntervalSeconds, + serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds, realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy, realmMandatoryTopics: initialSnapshot.realmMandatoryTopics, realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, + realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName, realmAllowMessageEditing: initialSnapshot.realmAllowMessageEditing, @@ -480,6 +491,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, + savedSnippets: SavedSnippetStoreImpl( + core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier( core: core, typingStoppedWaitPeriod: Duration( @@ -489,10 +502,15 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor ), users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), typingStatus: TypingStatus(core: core, - typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), - ), + typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)), + presence: Presence(core: core, + serverPresencePingInterval: Duration(seconds: initialSnapshot.serverPresencePingIntervalSeconds), + serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds, + realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, + initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(core: core), + messages: MessageStoreImpl(core: core, + realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), unreads: Unreads( initial: initialSnapshot.unreadMsgs, core: core, @@ -506,9 +524,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor PerAccountStore._({ required super.core, + required this.serverPresencePingIntervalSeconds, + required this.serverPresenceOfflineThresholdSeconds, required this.realmWildcardMentionPolicy, required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, + required this.realmPresenceDisabled, required this.maxFileUploadSizeMib, required String? realmEmptyTopicDisplayName, required this.realmAllowMessageEditing, @@ -518,9 +539,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.emailAddressVisibility, required EmojiStoreImpl emoji, required this.userSettings, + required SavedSnippetStoreImpl savedSnippets, required this.typingNotifier, required UserStoreImpl users, required this.typingStatus, + required this.presence, required ChannelStoreImpl channels, required MessageStoreImpl messages, required this.unreads, @@ -528,6 +551,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.recentSenders, }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, _emoji = emoji, + _savedSnippets = savedSnippets, _users = users, _channels = channels, _messages = messages; @@ -558,17 +582,16 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the realm or the server. - /// Always equal to `connection.zulipFeatureLevel` - /// and `account.zulipFeatureLevel`. - int get zulipFeatureLevel => connection.zulipFeatureLevel!; + final int serverPresencePingIntervalSeconds; + final int serverPresenceOfflineThresholdSeconds; - String get zulipVersion => account.zulipVersion; final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting final bool realmAllowMessageEditing; // TODO(#668): update this realm setting final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting + final bool realmPresenceDisabled; // TODO(#668): update this realm setting final int maxFileUploadSizeMib; // No event for this. /// The display name to use for empty topics. @@ -577,6 +600,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor /// be empty otherwise. // TODO(server-10) simplify this String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); assert(_realmEmptyTopicDisplayName != null); // TODO(log) return _realmEmptyTopicDisplayName ?? 'general chat'; } @@ -609,6 +633,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor notifyListeners(); } + @override + Iterable popularEmojiCandidates() => _emoji.popularEmojiCandidates(); + @override Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); @@ -619,6 +646,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final UserSettings? userSettings; // TODO(server-5) + @override + Map get savedSnippets => _savedSnippets.savedSnippets; + final SavedSnippetStoreImpl _savedSnippets; + final TypingNotifier typingNotifier; //////////////////////////////// @@ -630,10 +661,16 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Iterable get allUsers => _users.allUsers; + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) => + _users.isUserMuted(userId, event: event); + final UserStoreImpl _users; final TypingStatus typingStatus; + final Presence presence; + /// Whether [user] has passed the realm's waiting period to be a full member. /// /// See: @@ -656,10 +693,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } - /// The given user's real email address, if known, for displaying in the UI. + /// The user's real email address, if known, for displaying in the UI. /// - /// Returns null if self-user isn't able to see [user]'s real email address. - String? userDisplayEmail(User user) { + /// Returns null if self-user isn't able to see the user's real email address, + /// or if the user isn't actually a user we know about. + String? userDisplayEmail(int userId) { + final user = getUser(userId); + if (user == null) return null; if (zulipFeatureLevel >= 163) { // TODO(server-7) // A non-null value means self-user has access to [user]'s real email, // while a null value means it doesn't have access to the email. @@ -731,17 +771,50 @@ 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 void unregisterMessageList(MessageListView view) => _messages.unregisterMessageList(view); @override + void markReadFromScroll(Iterable messageIds) => + _messages.markReadFromScroll(messageIds); + @override + Future sendMessage({required MessageDestination destination, required String content}) { + assert(!_disposed); + 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 // TODO(#650) notify [recentDmConversationsView] of the just-fetched messages } + @override + bool? getEditMessageErrorStatus(int messageId) { + assert(!_disposed); + return _messages.getEditMessageErrorStatus(messageId); + } + @override + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) { + assert(!_disposed); + return _messages.editMessage(messageId: messageId, + originalRawContent: originalRawContent, newContent: newContent); + } + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); + return _messages.takeFailedMessageEdit(messageId); + } @override Set get debugMessageListViews => _messages.debugMessageListViews; @@ -841,6 +914,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); + case SavedSnippetsEvent(): + assert(debugLog('server event: saved_snippets/${event.op}')); + _savedSnippets.handleSavedSnippetsEvent(event); + notifyListeners(); + case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _channels.handleChannelEvent(event); @@ -895,21 +973,24 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor assert(debugLog("server event: typing/${event.op} ${event.messageType}")); typingStatus.handleTypingEvent(event); + case PresenceEvent(): + // TODO handle + break; + case ReactionEvent(): assert(debugLog("server event: reaction/${event.op}")); _messages.handleReactionEvent(event); + case MutedUsersEvent(): + assert(debugLog("server event: muted_users")); + _users.handleMutedUsersEvent(event); + notifyListeners(); + case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } } - @override - Future sendMessage({required MessageDestination destination, required String content}) { - assert(!_disposed); - return _messages.sendMessage(destination: destination, content: content); - } - static List _sortCustomProfileFields(List initialCustomProfileFields) { // TODO(server): The realm-wide field objects have an `order` property, // but the actual API appears to be that the fields should be shown in @@ -1022,7 +1103,7 @@ class LiveGlobalStore extends GlobalStore { // What directory should we use? // path_provider's getApplicationSupportDirectory: // on Android, -> Flutter's PathUtils.getFilesDir -> https://developer.android.com/reference/android/content/Context#getFilesDir() - // -> empirically /data/data/com.zulip.flutter/files/ + // -> empirically /data/data/com.zulipmobile/files/ // on iOS, -> "Library/Application Support" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsapplicationsupportdirectory // on Linux, -> "${XDG_DATA_HOME:-~/.local/share}/com.zulip.flutter/" // All seem reasonable. @@ -1158,6 +1239,7 @@ class UpdateMachine { // TODO do registerNotificationToken before registerQueue: // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 unawaited(updateMachine.registerNotificationToken()); + store.presence.start(); return updateMachine; } diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 254b615452..ad6b7b4b8d 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -197,6 +197,9 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { // TODO: Implement unreads handling. int countInStarredMessagesNarrow() => 0; + // TODO: Implement unreads handling? + int countInKeywordSearchNarrow() => 0; + int countInNarrow(Narrow narrow) { switch (narrow) { case CombinedFeedNarrow(): @@ -211,6 +214,8 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { return countInMentionsNarrow(); case StarredMessagesNarrow(): return countInStarredMessagesNarrow(); + case KeywordSearchNarrow(): + return countInKeywordSearchNarrow(); } } @@ -441,22 +446,20 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { notifyListeners(); } - /// To be called on success of a mark-all-as-read task in the modern protocol. + /// To be called on success of a mark-all-as-read task. /// /// When the user successfully marks all messages as read, /// there can't possibly be ancient unreads we don't know about. /// So this updates [oldUnreadsMissing] to false and calls [notifyListeners]. /// - /// When we use POST /messages/flags/narrow (FL 155+) for mark-all-as-read, - /// we don't expect to get a mark-as-read event with `all: true`, + /// We don't expect to get a mark-as-read event with `all: true`, /// even on completion of the last batch of unreads. - /// If we did get an event with `all: true` (as we do in the legacy mark-all- + /// If we did get an event with `all: true` (as we did in a legacy mark-all- /// as-read protocol), this would be handled naturally, in /// [handleUpdateMessageFlagsEvent]. /// /// Discussion: /// - // TODO(server-6) Delete mentions of legacy protocol. void handleAllMessagesReadSuccess() { oldUnreadsMissing = false; diff --git a/lib/model/user.dart b/lib/model/user.dart index 05ab2747df..3c68154e22 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -44,28 +44,47 @@ mixin UserStore on PerAccountStoreBase { /// The name to show the given user as in the UI, even for unknown users. /// - /// This is the user's [User.fullName] if the user is known, - /// and otherwise a translation of "(unknown user)". + /// If the user is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise this is the user's [User.fullName] if the user is known, + /// or (if unknown) [ZulipLocalizations.unknownUserName]. /// /// When a [Message] is available which the user sent, /// use [senderDisplayName] instead for a better-informed fallback. - String userDisplayName(int userId) { + String userDisplayName(int userId, {bool replaceIfMuted = true}) { + if (replaceIfMuted && isUserMuted(userId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } return getUser(userId)?.fullName ?? GlobalLocalizations.zulipLocalizations.unknownUserName; } /// The name to show for the given message's sender in the UI. /// - /// If the user is known (see [getUser]), this is their current [User.fullName]. + /// If the sender is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise, if the user is known (see [getUser]), + /// this is their current [User.fullName]. /// If unknown, this uses the fallback value conveniently provided on the /// [Message] object itself, namely [Message.senderFullName]. /// /// For a user who isn't the sender of some known message, /// see [userDisplayName]. - String senderDisplayName(Message message) { - return getUser(message.senderId)?.fullName - ?? message.senderFullName; + String senderDisplayName(Message message, {bool replaceIfMuted = true}) { + final senderId = message.senderId; + if (replaceIfMuted && isUserMuted(senderId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } + return getUser(senderId)?.fullName ?? message.senderFullName; } + + /// Whether the user with [userId] is muted by the self-user. + /// + /// Looks for [userId] in a private [Set], + /// or in [event.mutedUsers] instead if event is non-null. + bool isUserMuted(int userId, {MutedUsersEvent? event}); } /// The implementation of [UserStore] that does the work. @@ -81,7 +100,8 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { initialSnapshot.realmUsers .followedBy(initialSnapshot.realmNonActiveUsers) .followedBy(initialSnapshot.crossRealmBots) - .map((user) => MapEntry(user.userId, user))); + .map((user) => MapEntry(user.userId, user))), + _mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)); final Map _users; @@ -91,6 +111,13 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { @override Iterable get allUsers => _users.values; + final Set _mutedUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) { + return (event?.mutedUsers.map((item) => item.id) ?? _mutedUsers).contains(userId); + } + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -129,4 +156,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } } + + void handleMutedUsersEvent(MutedUsersEvent event) { + _mutedUsers.clear(); + _mutedUsers.addAll(event.mutedUsers.map((item) => item.id)); + } } diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 00a5f6fda5..7a66b1d19f 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -3,24 +3,18 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart' hide Notification; import 'package:http/http.dart' as http; import '../api/model/model.dart'; import '../api/notifications.dart'; -import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; -import '../widgets/app.dart'; import '../widgets/color.dart'; -import '../widgets/dialog.dart'; -import '../widgets/message_list.dart'; -import '../widgets/page.dart'; -import '../widgets/store.dart'; import '../widgets/theme.dart'; +import 'open.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; @@ -43,11 +37,12 @@ enum NotificationSound { class NotificationChannelManager { /// The channel ID we use for our one notification channel, which we use for /// all notifications. - // TODO(launch) check this doesn't match zulip-mobile's current or previous - // channel IDs - // Previous values: 'messages-1' + // Previous values from Zulip Flutter Beta: + // 'messages-1' + // Previous values from Zulip Mobile: + // 'default', 'messages-1', (alpha-only: 'messages-2'), 'messages-3' @visibleForTesting - static const kChannelId = 'messages-2'; + static const kChannelId = 'messages-4'; @visibleForTesting static const kDefaultNotificationSound = NotificationSound.chime3; @@ -64,14 +59,14 @@ class NotificationChannelManager { /// For example, for a resource `@raw/chime3`, where `raw` would be the /// resource type and `chime3` would be the resource name it generates the /// following URL: - /// `android.resource://com.zulip.flutter/raw/chime3` + /// `android.resource://com.zulipmobile/raw/chime3` /// /// Based on: https://stackoverflow.com/a/38340580 - static Uri _resourceUrlFromName({ + static Future _resourceUrlFromName({ required String resourceTypeName, required String resourceEntryName, - }) { - const packageName = 'com.zulip.flutter'; // TODO(#407) + }) async { + final packageInfo = await ZulipBinding.instance.packageInfo; // URL scheme for Android resource url. // See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE @@ -79,9 +74,9 @@ class NotificationChannelManager { return Uri( scheme: schemeAndroidResource, - host: packageName, + host: packageInfo!.packageName, pathSegments: [resourceTypeName, resourceEntryName], - ); + ).toString(); } /// Prepare our notification sounds; return a URL for our default sound. @@ -92,9 +87,9 @@ class NotificationChannelManager { /// Returns a URL for our default notification sound: either in shared storage /// if we successfully copied it there, or else as our internal resource file. static Future _ensureInitNotificationSounds() async { - String defaultSoundUrl = _resourceUrlFromName( + String defaultSoundUrl = await _resourceUrlFromName( resourceTypeName: 'raw', - resourceEntryName: kDefaultNotificationSound.resourceName).toString(); + resourceEntryName: kDefaultNotificationSound.resourceName); final shouldUseResourceFile = switch (await ZulipBinding.instance.deviceInfo) { // Before Android 10 Q, we don't attempt to put the sounds in shared media storage. @@ -302,7 +297,7 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); await _androidHost.notify( id: kNotificationId, @@ -481,62 +476,6 @@ class NotificationDisplayManager { static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId"; - /// Provides the route and the account ID by parsing the notification URL. - /// - /// The URL must have been generated using [NotificationOpenPayload.buildUrl] - /// while creating the notification. - /// - /// Returns null and shows an error dialog if the associated account is not - /// found in the global store. - static AccountRoute? routeForNotification({ - required BuildContext context, - required Uri url, - }) { - assert(defaultTargetPlatform == TargetPlatform.android); - - final globalStore = GlobalStoreWidget.of(context); - - assert(debugLog('got notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); - - final account = globalStore.accounts.firstWhereOrNull( - (account) => account.realmUrl.origin == payload.realmUrl.origin - && account.userId == payload.userId); - if (account == null) { // TODO(log) - final zulipLocalizations = ZulipLocalizations.of(context); - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); - return null; - } - - return MessageListPage.buildRoute( - accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - narrow: payload.narrow); - } - - /// Navigates to the [MessageListPage] of the specific conversation - /// given the `zulip://notification/…` Android intent data URL, - /// generated with [NotificationOpenPayload.buildUrl] while creating - /// the notification. - static Future navigateForNotification(Uri url) async { - assert(defaultTargetPlatform == TargetPlatform.android); - assert(debugLog('opened notif: url: $url')); - - NavigatorState navigator = await ZulipApp.navigator; - final context = navigator.context; - assert(context.mounted); - if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that - - final route = routeForNotification(context: context, url: url); - if (route == null) return; // TODO(log) - - // TODO(nav): Better interact with existing nav stack on notif open - unawaited(navigator.push(route)); - } - static Future _fetchBitmap(Uri url) async { try { // TODO timeout to prevent waiting indefinitely @@ -550,86 +489,3 @@ class NotificationDisplayManager { return null; } } - -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { - final Uri realmUrl; - final int userId; - final Narrow narrow; - - NotificationOpenPayload({ - required this.realmUrl, - required this.userId, - required this.narrow, - }); - - factory NotificationOpenPayload.parseUrl(Uri url) { - if (url case Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': var realmUrlStr, - 'user_id': var userIdStr, - 'narrow_type': var narrowType, - // In case of narrowType == 'topic': - // 'channel_id' and 'topic' handled below. - - // In case of narrowType == 'dm': - // 'all_recipient_ids' handled below. - }, - )) { - final realmUrl = Uri.parse(realmUrlStr); - final userId = int.parse(userIdStr, radix: 10); - - final Narrow narrow; - switch (narrowType) { - case 'topic': - final channelIdStr = url.queryParameters['channel_id']!; - final channelId = int.parse(channelIdStr, radix: 10); - final topicStr = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, TopicName(topicStr)); - case 'dm': - final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; - final allRecipientIds = allRecipientIdsStr.split(',') - .map((idStr) => int.parse(idStr, radix: 10)) - .toList(growable: false); - narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); - default: - throw const FormatException(); - } - - return NotificationOpenPayload( - realmUrl: realmUrl, - userId: userId, - narrow: narrow, - ); - } else { - // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 - throw const FormatException(); - } - } - - Uri buildUrl() { - return Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': realmUrl.toString(), - 'user_id': userId.toString(), - ...(switch (narrow) { - TopicNarrow(streamId: var channelId, :var topic) => { - 'narrow_type': 'topic', - 'channel_id': channelId.toString(), - 'topic': topic.apiName, - }, - DmNarrow(:var allRecipientIds) => { - 'narrow_type': 'dm', - 'all_recipient_ids': allRecipientIds.join(','), - }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), - }) - }, - ); - } -} diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart new file mode 100644 index 0000000000..2eb281473c --- /dev/null +++ b/lib/notifications/open.dart @@ -0,0 +1,346 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../host/notifications.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import '../widgets/app.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi; + +/// Responds to the user opening a notification. +class NotificationOpenService { + static NotificationOpenService get instance => (_instance ??= NotificationOpenService._()); + static NotificationOpenService? _instance; + + NotificationOpenService._(); + + /// Reset the state of the [NotificationNavigationService], for testing. + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationNavigationService] has completed + /// (with either success or failure). + /// + /// Null if [start] hasn't been called. + Future? get initialized => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future start() async { + assert(_initializedSignal == null); + _initializedSignal = Completer(); + try { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + _notifPigeonApi.notificationTapEventsStream() + .listen(_navigateForNotification); + + case TargetPlatform.android: + // Do nothing; we do notification routing differently on Android. + // TODO migrate Android to use the new Pigeon API. + break; + + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't offer notifications on these platforms. + break; + } + } finally { + _initializedSignal!.complete(); + } + } + + /// Provides the route to open if the app was launched through a tap on + /// a notification. + /// + /// Returns null if app launch wasn't triggered by a notification, or if + /// an error occurs while determining the route for the notification. + /// In the latter case an error dialog is also shown. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParseIosApnsPayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + + return routeForNotification(context: context, data: notifNavData); + } + + /// Provides the route to open by parsing the notification payload. + /// + /// Returns null and shows an error dialog if the associated account is not + /// found in the global store. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + static AccountRoute? routeForNotification({ + required BuildContext context, + required NotificationOpenPayload data, + }) { + final globalStore = GlobalStoreWidget.of(context); + + final account = globalStore.accounts.firstWhereOrNull( + (account) => account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountNotFound); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#1565): Open at specific message, not just conversation + narrow: data.narrow); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// for the provided payload that was attached while creating the + /// notification. + static Future _navigateForNotification(NotificationTapEvent event) async { + assert(defaultTargetPlatform == TargetPlatform.iOS); + assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final notifNavData = _tryParseIosApnsPayload(context, event.payload); + if (notifNavData == null) return; // TODO(log) + final route = routeForNotification(context: context, data: notifNavData); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// given the `zulip://notification/…` Android intent data URL, + /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] + /// while creating the notification. + static Future navigateForAndroidNotificationUrl(Uri url) async { + assert(defaultTargetPlatform == TargetPlatform.android); + assert(debugLog('opened notif: url: $url')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + assert(url.scheme == 'zulip' && url.host == 'notification'); + final data = tryParseAndroidNotificationUrl(context: context, url: url); + if (data == null) return; // TODO(log) + final route = routeForNotification(context: context, data: data); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + static NotificationOpenPayload? _tryParseIosApnsPayload( + BuildContext context, + Map payload, + ) { + try { + return NotificationOpenPayload.parseIosApnsPayload(payload); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } + + static NotificationOpenPayload? tryParseAndroidNotificationUrl({ + required BuildContext context, + required Uri url, + }) { + try { + return NotificationOpenPayload.parseAndroidNotificationUrl(url); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } +} + +/// The data from a notification that describes what to do +/// when the user opens the notification. +class NotificationOpenPayload { + final Uri realmUrl; + final int userId; + final Narrow narrow; + + NotificationOpenPayload({ + required this.realmUrl, + required this.userId, + required this.narrow, + }); + + /// Parses the iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationOpenPayload.parseIosApnsPayload(Map payload) { + if (payload case { + 'zulip': { + 'user_id': final int userId, + 'sender_id': final int senderId, + } && final zulipData, + }) { + final eventType = zulipData['event']; + if (eventType != null && eventType != 'message') { + // On Android, we also receive "remove" notification messages, tagged + // with an `event` field with value 'remove'. As of Zulip Server 10, + // however, these are not yet sent to iOS devices, and we don't have a + // way to handle them even if they were. + // + // The messages we currently do receive, and can handle, are analogous + // to Android notification messages of event type 'message'. On the + // assumption that some future version of the Zulip server will send + // explicit event types in APNs messages, accept messages with that + // `event` value, but no other. + throw const FormatException(); + } + + final realmUrl = switch (zulipData) { + {'realm_url': final String value} => value, + {'realm_uri': final String value} => value, + _ => throw const FormatException(), + }; + + final narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + // TODO(server-5) remove this comment. + // We require 'stream_id' here but that is new from Server 5.0, + // resulting in failure on pre-5.0 servers. + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false) + ..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationOpenPayload( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + /// Parses the internal Android notification url, that was created using + /// [buildAndroidNotificationUrl], and retrieves the information required + /// for navigation. + factory NotificationOpenPayload.parseAndroidNotificationUrl(Uri url) { + if (url case Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': var realmUrlStr, + 'user_id': var userIdStr, + 'narrow_type': var narrowType, + // In case of narrowType == 'topic': + // 'channel_id' and 'topic' handled below. + + // In case of narrowType == 'dm': + // 'all_recipient_ids' handled below. + }, + )) { + final realmUrl = Uri.parse(realmUrlStr); + final userId = int.parse(userIdStr, radix: 10); + + final Narrow narrow; + switch (narrowType) { + case 'topic': + final channelIdStr = url.queryParameters['channel_id']!; + final channelId = int.parse(channelIdStr, radix: 10); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); + case 'dm': + final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; + final allRecipientIds = allRecipientIdsStr.split(',') + .map((idStr) => int.parse(idStr, radix: 10)) + .toList(growable: false); + narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); + default: + throw const FormatException(); + } + + return NotificationOpenPayload( + realmUrl: realmUrl, + userId: userId, + narrow: narrow, + ); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + Uri buildAndroidNotificationUrl() { + return Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': realmUrl.toString(), + 'user_id': userId.toString(), + ...(switch (narrow) { + TopicNarrow(streamId: var channelId, :var topic) => { + 'narrow_type': 'topic', + 'channel_id': channelId.toString(), + 'topic': topic.apiName, + }, + DmNarrow(:var allRecipientIds) => { + 'narrow_type': 'dm', + 'all_recipient_ids': allRecipientIds.join(','), + }, + _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), + }) + }, + ); + } +} diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index d60469ff30..212b0f5f0d 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -8,6 +8,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import 'display.dart'; +import 'open.dart'; @pragma('vm:entry-point') class NotificationService { @@ -24,6 +25,7 @@ class NotificationService { instance.token.dispose(); _instance = null; assert(debugBackgroundIsolateIsLive = true); + NotificationOpenService.debugReset(); } /// Whether a background isolate should initialize [LiveZulipBinding]. @@ -77,6 +79,8 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await NotificationOpenService.instance.start(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 88114d48bb..d040ea8bcf 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -12,6 +12,7 @@ import '../api/model/model.dart'; import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; @@ -28,6 +29,7 @@ import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( BuildContext context, { @@ -174,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, @@ -304,9 +325,7 @@ void showTopicActionSheet(BuildContext context, { // TODO: check for other cases that may disallow this action (e.g.: time // limit for editing topics). - if (someMessageIdInTopic != null - // ignore: unnecessary_null_comparison // null topic names soon to be enabled - && topic.displayName != null) { + if (someMessageIdInTopic != null && topic.displayName != null) { optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, topic: topic, someMessageIdInTopic: someMessageIdInTopic)); @@ -556,6 +575,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); + final popularEmojiLoaded = store.popularEmojiCandidates().isNotEmpty; + // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build // method; see its doc). @@ -568,16 +589,24 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final isSenderMuted = store.isUserMuted(message.senderId); + final optionButtons = [ - ReactionButtons(message: message, pageContext: pageContext), + if (popularEmojiLoaded) + ReactionButtons(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + // The message must have been revealed in order to open this action sheet. + UnrevealMutedMessageButton(message: message, pageContext: pageContext), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), + if (_getShouldShowEditButton(pageContext, message)) + EditButton(message: message, pageContext: pageContext), ]; _showActionSheet(pageContext, optionButtons: optionButtons); @@ -593,6 +622,36 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto final Message message; } +bool _getShouldShowEditButton(BuildContext pageContext, Message message) { + final store = PerAccountStoreWidget.of(pageContext); + + final messageListPage = MessageListPage.ancestorOf(pageContext); + final composeBoxState = messageListPage.composeBoxState; + final isComposeBoxOffered = composeBoxState != null; + final composeBoxController = composeBoxState?.controller; + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + final editMessageInProgress = + // The compose box is in edit-message mode, with Cancel/Save instead of Send. + composeBoxController is EditMessageComposeBoxController + // An edit request is in progress or the error state. + || editMessageErrorStatus != null; + + final now = ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000; + final editLimit = store.realmMessageContentEditLimitSeconds; + final outsideEditLimit = + editLimit != null + && editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) + && now - message.timestamp > editLimit; + + return message.senderId == store.selfUserId + && isComposeBoxOffered + && store.realmAllowMessageEditing + && !outsideEditLimit + && !editMessageInProgress + && message.poll == null; // messages with polls cannot be edited +} + class ReactionButtons extends StatelessWidget { const ReactionButtons({ super.key, @@ -667,11 +726,18 @@ class ReactionButtons extends StatelessWidget { @override Widget build(BuildContext context) { - assert(EmojiStore.popularEmojiCandidates.every( + final store = PerAccountStoreWidget.of(pageContext); + final popularEmojiCandidates = store.popularEmojiCandidates(); + assert(popularEmojiCandidates.every( (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); + // (if this is empty, the widget isn't built in the first place) + assert(popularEmojiCandidates.isNotEmpty); + // UI not designed to handle more than 6 popular emoji. + // (We might have fewer if ServerEmojiData is lacking expected data, + // but that looks fine in manual testing, even when there's just one.) + assert(popularEmojiCandidates.length <= 6); final zulipLocalizations = ZulipLocalizations.of(context); - final store = PerAccountStoreWidget.of(pageContext); final designVariables = DesignVariables.of(context); bool hasSelfVote(EmojiCandidate emoji) { @@ -687,7 +753,7 @@ class ReactionButtons extends StatelessWidget { color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)), child: Row(children: [ Flexible(child: Row(spacing: 1, children: List.unmodifiable( - EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) => + popularEmojiCandidates.mapIndexed((index, emoji) => _buildButton( context: context, emoji: emoji, @@ -772,7 +838,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionQuoteAndReply; + return zulipLocalizations.actionSheetOptionQuoteMessage; } @override void onPressed() async { @@ -835,9 +901,35 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } @override void onPressed() async { - final narrow = findMessageListPage().narrow; + final messageListPage = findMessageListPage(); unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext, - message, narrow)); + message, messageListPage.narrow)); + // TODO should we alert the user about this change somehow? A snackbar? + messageListPage.markReadOnScroll = false; + } +} + +class UnrevealMutedMessageButton extends MessageActionSheetMenuItemButton { + UnrevealMutedMessageButton({ + super.key, + required super.message, + required super.pageContext, + }); + + @override + IconData get icon => ZulipIcons.eye_off; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionHideMutedMessage; + } + + @override + void onPressed() { + // The message should have been revealed in order to reach this action sheet. + assert(MessageListPage.revealedMutedMessagesOf(pageContext) + .isMutedMessageRevealed(message.id)); + findMessageListPage().unrevealMutedMessage(message.id); } } @@ -941,7 +1033,8 @@ class ShareButton extends MessageActionSheetMenuItemButton { // https://pub.dev/packages/share_plus#ipad // Perhaps a wart in the API; discussion: // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 - final result = await Share.share(rawContent); + final result = + await SharePlus.instance.share(ShareParams(text: rawContent)); switch (result.status) { // The plugin isn't very helpful: "The status can not be determined". @@ -956,3 +1049,22 @@ class ShareButton extends MessageActionSheetMenuItemButton { } } } + +class EditButton extends MessageActionSheetMenuItemButton { + EditButton({super.key, required super.message, required super.pageContext}); + + @override + IconData get icon => ZulipIcons.edit; + + @override + String label(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.actionSheetOptionEditMessage; + + @override void onPressed() async { + final composeBoxState = findMessageListPage().composeBoxState; + if (composeBoxState == null) { + throw StateError('Compose box unexpectedly absent when edit-message button pressed'); + } + composeBoxState.startEditInteraction(message.id); + } +} diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index ad4557be08..4d96727666 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -26,25 +26,7 @@ abstract final class ZulipAction { /// This is mostly a wrapper around [updateMessageFlagsStartingFromAnchor]; /// for details on the UI feedback, see there. static Future markNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final useLegacy = store.zulipFeatureLevel < 155; // TODO(server-6) - if (useLegacy) { - try { - await _legacyMarkNarrowAsRead(context, narrow); - return; - } catch (e) { - if (!context.mounted) return; - final message = switch (e) { - ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), - _ => e.toString(), // TODO(#741): extract user-facing message better - }; - showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: message); - return; - } - } final didPass = await updateMessageFlagsStartingFromAnchor( context: context, @@ -208,39 +190,6 @@ abstract final class ZulipAction { } } - static Future _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - switch (narrow) { - case CombinedFeedNarrow(): - await markAllAsRead(connection); - case ChannelNarrow(:final streamId): - await markStreamAsRead(connection, streamId: streamId); - case TopicNarrow(:final streamId, :final topic): - await markTopicAsRead(connection, streamId: streamId, topicName: topic); - case DmNarrow(): - final unreadDms = store.unreads.dms[narrow]; - // Silently ignore this race-condition as the outcome - // (no unreads in this narrow) was the desired end-state - // of pushing the button. - if (unreadDms == null) return; - await updateMessageFlags(connection, - messages: unreadDms, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case MentionsNarrow(): - final unreadMentions = store.unreads.mentions.toList(); - if (unreadMentions.isEmpty) return; - await updateMessageFlags(connection, - messages: unreadMentions, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case StarredMessagesNarrow(): - // TODO: Implement unreads handling. - return; - } - } - /// Fetch and return the raw Markdown content for [messageId], /// showing an error dialog on failure. static Future fetchRawContentWithFeedback({ @@ -260,6 +209,7 @@ abstract final class ZulipAction { fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, messageId: messageId, applyMarkdown: false, + allowEmptyTopicName: true, ); if (fetchedMessage == null) { errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..d3ed5c463d 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -9,7 +9,7 @@ import '../log.dart'; import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; -import '../notifications/display.dart'; +import '../notifications/open.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -160,6 +160,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + UpgradeWelcomeDialog.maybeShow(); } @override @@ -168,27 +169,45 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } - List> _handleGenerateInitialRoutes(String initialRoute) { - // The `_ZulipAppState.context` lacks the required ancestors. Instead - // we use the Navigator which should be available when this callback is - // called and it's context should have the required ancestors. - final context = ZulipApp.navigatorKey.currentContext!; + AccountRoute? _initialRouteIos(BuildContext context) { + return NotificationOpenService.instance + .routeForNotificationFromLaunch(context: context); + } + // TODO migrate Android's notification navigation to use the new Pigeon API. + AccountRoute? _initialRouteAndroid( + BuildContext context, + String initialRoute, + ) { final initialRouteUrl = Uri.tryParse(initialRoute); if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - final route = NotificationDisplayManager.routeForNotification( + assert(debugLog('got notif: url: $initialRouteUrl')); + final data = NotificationOpenService.tryParseAndroidNotificationUrl( context: context, url: initialRouteUrl); + if (data == null) return null; // TODO(log) + return NotificationOpenService.routeForNotification( + context: context, + data: data); + } + + return null; + } + + List> _handleGenerateInitialRoutes(String initialRoute) { + // The `_ZulipAppState.context` lacks the required ancestors. Instead + // we use the Navigator which should be available when this callback is + // called and its context should have the required ancestors. + final context = ZulipApp.navigatorKey.currentContext!; - if (route != null) { - return [ - HomePage.buildRoute(accountId: route.accountId), - route, - ]; - } else { - // The account didn't match any existing accounts, - // fall through to show the default route below. - } + final route = defaultTargetPlatform == TargetPlatform.iOS + ? _initialRouteIos(context) + : _initialRouteAndroid(context, initialRoute); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; } final globalStore = GlobalStoreWidget.of(context); @@ -209,7 +228,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { await LoginPage.handleWebAuthUrl(url); return true; case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationDisplayManager.navigateForNotification(url); + await NotificationOpenService.navigateForAndroidNotificationUrl(url); return true; } return super.didPushRouteInformation(routeInformation); @@ -218,6 +237,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return GlobalStoreWidget( + blockingFuture: NotificationOpenService.instance.initialized, child: Builder(builder: (context) { return MaterialApp( onGenerateTitle: (BuildContext context) { diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a31369c3d9..bfb633ee66 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -130,7 +130,7 @@ class _AutocompleteFieldState) - fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + fieldViewBuilder: (context, _, _, _) => widget.fieldViewBuilder(context), ); } } @@ -275,10 +275,9 @@ class _MentionAutocompleteItem extends StatelessWidget { String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): - final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult avatar = Avatar(userId: userId, size: 36, borderRadius: 4); - label = user.fullName; - sublabel = store.userDisplayEmail(user); + label = store.userDisplayName(userId); + sublabel = store.userDisplayEmail(userId); case WildcardMentionAutocompleteResult(:var wildcardOption): avatar = SizedBox.square(dimension: 36, child: const Icon(ZulipIcons.three_person, size: 24)); @@ -416,13 +415,11 @@ class TopicAutocomplete extends AutocompleteField(T small, T normal) => + switch (size) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -104,27 +136,41 @@ class ZulipWebUiKitButton extends StatelessWidget { // from shrinking to zero as the button grows to accommodate a larger label final textScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + final buttonHeight = _forSize(24, 28); + + final labelColor = _labelColor(designVariables); + return AnimatedScaleOnTap( scaleEnd: 0.96, duration: Duration(milliseconds: 100), - child: TextButton( + child: TextButton.icon( + // TODO the gap between the icon and label should be 6px, not 8px + icon: icon != null ? Icon(icon) : null, style: TextButton.styleFrom( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4 - densityVerticalAdjustment), - foregroundColor: _labelColor(designVariables), + iconSize: 16, + iconColor: labelColor, + padding: EdgeInsets.symmetric( + horizontal: _forSize(6, 10), + vertical: 4 - densityVerticalAdjustment, + ), + foregroundColor: labelColor, shape: RoundedRectangleBorder( side: _borderSide(designVariables), - borderRadius: BorderRadius.circular(4)), + borderRadius: BorderRadius.circular(_forSize(6, 4))), splashFactory: NoSplash.splashFactory, - // These three arguments make the button 28px tall vertically, + // These three arguments make the button `buttonHeight` tall, // but with vertical padding to make the touch target 44px tall: // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 visualDensity: visualDensity, tapTargetSize: MaterialTapTargetSize.padded, - minimumSize: Size(kMinInteractiveDimension, 28 - densityVerticalAdjustment), + minimumSize: Size( + kMinInteractiveDimension, + buttonHeight - densityVerticalAdjustment, + ), ).copyWith(backgroundColor: _backgroundColor(designVariables)), onPressed: onPressed, - child: ConstrainedBox( + label: ConstrainedBox( constraints: BoxConstraints(maxWidth: 240), child: Text(label, textScaler: textScaler, @@ -139,10 +185,15 @@ enum ZulipWebUiKitButtonAttention { high, medium, // low, + + /// An ad hoc value for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + minimal, } enum ZulipWebUiKitButtonIntent { - // neutral, + neutral, // warning, // danger, info, @@ -150,6 +201,17 @@ enum ZulipWebUiKitButtonIntent { // brand, } +enum ZulipWebUiKitButtonSize { + /// A smaller size than the one in the Zulip Web UI Kit. + /// + /// This was ad hoc for mobile, for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + small, + + normal, +} + /// Apply [Transform.scale] to the child widget when tapped, and reset its scale /// when released, while animating the transitions. class AnimatedScaleOnTap extends StatefulWidget { diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 11d27d5402..6514631955 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; @@ -12,9 +13,12 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; +import '../model/message.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import 'actions.dart'; import 'autocomplete.dart'; +import 'button.dart'; import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; @@ -82,6 +86,8 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { + ComposeController({super.text}); + int get maxLengthUnicodeCodePoints; String get textNormalized => _textNormalized; @@ -143,7 +149,7 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController({required this.store}) { + ComposeTopicController({super.text, required this.store}) { _update(); } @@ -201,7 +207,6 @@ class ComposeTopicController extends ComposeController { } void setTopic(TopicName newTopic) { - // ignore: dead_null_aware_expression // null topic names soon to be enabled value = TextEditingValue(text: newTopic.displayName ?? ''); } } @@ -227,10 +232,13 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController() { + ComposeContentController({super.text, this.requireNotEmpty = true}) { _update(); } + /// Whether to produce [ContentValidationError.empty]. + final bool requireNotEmpty; + // TODO(#1237) use `max_message_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; @@ -377,7 +385,7 @@ class ComposeContentController extends ComposeController @override List _computeValidationErrors() { return [ - if (textNormalized.isEmpty) + if (requireNotEmpty && textNormalized.isEmpty) ContentValidationError.empty, if ( @@ -491,12 +499,14 @@ class _ContentInput extends StatelessWidget { const _ContentInput({ required this.narrow, required this.controller, - required this.hintText, + this.hintText, + this.enabled = true, }); final Narrow narrow; final ComposeBoxController controller; - final String hintText; + final String? hintText; + final bool enabled; static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) @@ -539,6 +549,7 @@ class _ContentInput extends StatelessWidget { top: _verticalPadding, bottom: _verticalPadding, color: designVariables.composeBoxBg, child: TextField( + enabled: enabled, controller: controller.content, focusNode: controller.contentFocusNode, // Let the content show through the `contentPadding` so that @@ -596,11 +607,18 @@ class _StreamContentInputState extends State<_StreamContentInput> { }); } + void _topicInteractionStatusChanged() { + setState(() { + // The relevant state lives on widget.controller.topicInteractionStatus itself. + }); + } + @override void initState() { super.initState(); widget.controller.topic.addListener(_topicChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); } @override @@ -614,12 +632,17 @@ class _StreamContentInputState extends State<_StreamContentInput> { oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); } + if (widget.controller.topicInteractionStatus != oldWidget.controller.topicInteractionStatus) { + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } } @override void dispose() { widget.controller.topic.removeListener(_topicChanged); widget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); super.dispose(); } @@ -630,11 +653,11 @@ class _StreamContentInputState extends State<_StreamContentInput> { // The chosen topic can't be sent to, so don't show it. return null; } - if (!widget.controller.contentFocusNode.hasFocus) { - // Do not fall back to a vacuous topic unless the user explicitly chooses - // to do so (by skipping topic input and moving focus to content input), - // so that the user is not encouraged to use vacuous topic when they - // have not interacted with the inputs at all. + if (widget.controller.topicInteractionStatus.value != + ComposeTopicInteractionStatus.hasChosen) { + // Do not fall back to a vacuous topic unless the user explicitly + // chooses to do so, so that the user is not encouraged to use vacuous + // topic before they have interacted with the inputs at all. return null; } } @@ -656,7 +679,6 @@ class _StreamContentInputState extends State<_StreamContentInput> { // so don't make sense to translate. See: // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 ? '#$streamName' - // ignore: dead_null_aware_expression // null topic names soon to be enabled : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; return _TypingNotifier( @@ -670,41 +692,142 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } -class _TopicInput extends StatelessWidget { +class _TopicInput extends StatefulWidget { const _TopicInput({required this.streamId, required this.controller}); final int streamId; final StreamComposeBoxController controller; + @override + State<_TopicInput> createState() => _TopicInputState(); +} + +class _TopicInputState extends State<_TopicInput> { + void _topicOrContentFocusChanged() { + setState(() { + final status = widget.controller.topicInteractionStatus; + if (widget.controller.topicFocusNode.hasFocus) { + // topic input gains focus + status.value = ComposeTopicInteractionStatus.isEditing; + } else if (widget.controller.contentFocusNode.hasFocus) { + // content input gains focus + status.value = ComposeTopicInteractionStatus.hasChosen; + } else { + // neither input has focus, the new value of topicInteractionStatus + // depends on its previous value + if (status.value == ComposeTopicInteractionStatus.isEditing) { + // topic input loses focus + status.value = ComposeTopicInteractionStatus.notEditingNotChosen; + } else { + // content input loses focus; stay in hasChosen + assert(status.value == ComposeTopicInteractionStatus.hasChosen); + } + } + }); + } + + void _topicInteractionStatusChanged() { + setState(() { + // The actual state lives in widget.controller.topicInteractionStatus + }); + } + + @override + void initState() { + super.initState(); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + + @override + void didUpdateWidget(covariant _TopicInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + } + + @override + void dispose() { + widget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); - TextStyle topicTextStyle = TextStyle( + final store = PerAccountStoreWidget.of(context); + + final topicTextStyle = TextStyle( fontSize: 20, height: 22 / 20, color: designVariables.textInput.withFadedAlpha(0.9), ).merge(weightVariableTextStyle(context, wght: 600)); + // TODO(server-10) simplify away + final emptyTopicsSupported = store.zulipFeatureLevel >= 334; + + final String hintText; + TextStyle hintStyle = topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5)); + + if (store.realmMandatoryTopics) { + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + } else { + switch (widget.controller.topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + case ComposeTopicInteractionStatus.isEditing: + // The user is actively interacting with the input. Since topics are + // not mandatory, show a long hint text mentioning that they can be + // left empty. + hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText( + emptyTopicsSupported + ? store.realmEmptyTopicDisplayName + : kNoTopicTopic); + case ComposeTopicInteractionStatus.hasChosen: + // The topic has likely been chosen. Since topics are not mandatory, + // show the default topic display name as if the user has entered that + // when they left the input empty. + if (emptyTopicsSupported) { + hintText = store.realmEmptyTopicDisplayName; + hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic); + } else { + hintText = kNoTopicTopic; + hintStyle = topicTextStyle; + } + } + } + + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + return TopicAutocomplete( - streamId: streamId, - controller: controller.topic, - focusNode: controller.topicFocusNode, - contentFocusNode: controller.contentFocusNode, + streamId: widget.streamId, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, + contentFocusNode: widget.controller.contentFocusNode, fieldViewBuilder: (context) => Container( padding: const EdgeInsets.only(top: 10, bottom: 9), decoration: BoxDecoration(border: Border(bottom: BorderSide( width: 1, color: designVariables.foreground.withFadedAlpha(0.2)))), child: TextField( - controller: controller.topic, - focusNode: controller.topicFocusNode, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + decoration: decoration))); } } @@ -729,7 +852,6 @@ class _FixedDestinationContentInput extends StatelessWidget { // Zulip expresses channels and topics, not any normal English punctuation, // so don't make sense to translate. See: // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 - // ignore: dead_null_aware_expression // null topic names soon to be enabled '#$streamName > ${topic.displayName ?? store.realmEmptyTopicDisplayName}'); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. @@ -737,9 +859,11 @@ class _FixedDestinationContentInput extends StatelessWidget { case DmNarrow(otherRecipientIds: [final otherUserId]): final store = PerAccountStoreWidget.of(context); - final fullName = store.getUser(otherUserId)?.fullName; - if (fullName == null) return zulipLocalizations.composeBoxGenericContentHint; - return zulipLocalizations.composeBoxDmContentHint(fullName); + final user = store.getUser(otherUserId); + if (user == null) return zulipLocalizations.composeBoxGenericContentHint; + // TODO write a test where the user is muted + return zulipLocalizations.composeBoxDmContentHint( + store.userDisplayName(otherUserId, replaceIfMuted: false)); case DmNarrow(): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; @@ -758,6 +882,31 @@ class _FixedDestinationContentInput extends StatelessWidget { } } +class _EditMessageContentInput extends StatelessWidget { + const _EditMessageContentInput({ + required this.narrow, + required this.controller, + }); + + final Narrow narrow; + final EditMessageComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final awaitingRawContent = ComposeBoxInheritedWidget.of(context) + .awaitingRawMessageContentForEdit; + return _ContentInput( + narrow: narrow, + controller: controller, + enabled: !awaitingRawContent, + hintText: awaitingRawContent + ? zulipLocalizations.preparingEditMessageContentInput + : null, + ); + } +} + /// Data on a file to be uploaded, from any source. /// /// A convenience class to represent data from the generic file picker, @@ -845,9 +994,10 @@ Future _uploadFiles({ } abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.controller}); + const _AttachUploadsButton({required this.controller, required this.enabled}); final ComposeBoxController controller; + final bool enabled; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); @@ -889,7 +1039,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { child: IconButton( icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + onPressed: enabled ? () => _handlePress(context) : null)); } } @@ -948,7 +1098,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) } class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.controller}); + const _AttachFileButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.attach_file; @@ -964,7 +1114,7 @@ class _AttachFileButton extends _AttachUploadsButton { } class _AttachMediaButton extends _AttachUploadsButton { - const _AttachMediaButton({required super.controller}); + const _AttachMediaButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.image; @@ -981,7 +1131,7 @@ class _AttachMediaButton extends _AttachUploadsButton { } class _AttachFromCameraButton extends _AttachUploadsButton { - const _AttachFromCameraButton({required super.controller}); + const _AttachFromCameraButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.camera; @@ -1141,15 +1291,8 @@ class _SendButtonState extends State<_SendButton> { final content = controller.content.textNormalized; controller.content.clear(); - // The following `stoppedComposing` call is currently redundant, - // because clearing input sends a "typing stopped" notice. - // It will be necessary once we resolve #720. - store.typingNotifier.stoppedComposing(); try { - // TODO(#720) clear content input only on success response; - // while waiting, put input(s) and send button into a disabled - // "working on it" state (letting input text be selected for copying). await store.sendMessage(destination: widget.getDestination(), content: content); } on ApiRequestException catch (e) { if (!mounted) return; @@ -1241,7 +1384,6 @@ class _ComposeBoxContainer extends StatelessWidget { border: Border(top: BorderSide(color: designVariables.borderBar)), boxShadow: ComposeBoxTheme.of(context).boxShadow, ), - // TODO(#720) try a Stack for the overlaid linear progress indicator child: Material( color: designVariables.composeBoxBg, child: Column( @@ -1258,7 +1400,8 @@ abstract class _ComposeBoxBody extends StatelessWidget { Widget? buildTopicInput(); Widget buildContentInput(); - Widget buildSendButton(); + bool getComposeButtonsEnabled(BuildContext context); + Widget? buildSendButton(); @override Widget build(BuildContext context) { @@ -1284,13 +1427,15 @@ abstract class _ComposeBoxBody extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4))))); + final composeButtonsEnabled = getComposeButtonsEnabled(context); final composeButtons = [ - _AttachFileButton(controller: controller), - _AttachMediaButton(controller: controller), - _AttachFromCameraButton(controller: controller), + _AttachFileButton(controller: controller, enabled: composeButtonsEnabled), + _AttachMediaButton(controller: controller, enabled: composeButtonsEnabled), + _AttachFromCameraButton(controller: controller, enabled: composeButtonsEnabled), ]; final topicInput = buildTopicInput(); + final sendButton = buildSendButton(); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1308,7 +1453,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: composeButtons), - buildSendButton(), + if (sendButton != null) sendButton, ]))), ]); } @@ -1337,6 +1482,8 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( @@ -1360,16 +1507,49 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => narrow.destination, ); } +/// A compose box for editing an already-sent message. +class _EditMessageComposeBoxBody extends _ComposeBoxBody { + _EditMessageComposeBoxBody({required this.narrow, required this.controller}); + + @override + final Narrow narrow; + + @override + final EditMessageComposeBoxController controller; + + @override Widget? buildTopicInput() => null; + + @override Widget buildContentInput() => _EditMessageContentInput( + narrow: narrow, + controller: controller); + + @override bool getComposeButtonsEnabled(BuildContext context) => + !ComposeBoxInheritedWidget.of(context).awaitingRawMessageContentForEdit; + + @override Widget? buildSendButton() => null; +} + sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + /// If no input is focused, requests focus on the appropriate input. + /// + /// This encapsulates choosing the topic or content input + /// when both exist (see [StreamComposeBoxController.requestFocusIfUnfocused]). + void requestFocusIfUnfocused() { + if (contentFocusNode.hasFocus) return; + contentFocusNode.requestFocus(); + } + @mustCallSuper void dispose() { content.dispose(); @@ -1377,23 +1557,108 @@ sealed class ComposeBoxController { } } +/// Represent how a user has interacted with topic and content inputs. +/// +/// State-transition diagram: +/// +/// ``` +/// (default) +/// Topic input │ Content input +/// lost focus. ▼ gained focus. +/// ┌────────────► notEditingNotChosen ────────────┐ +/// │ │ │ +/// │ Topic input │ │ +/// │ gained focus. │ │ +/// │ ◄─────────────────────────┘ ▼ +/// isEditing ◄───────────────────────────── hasChosen +/// │ Focus moved from ▲ │ ▲ +/// │ content to topic. │ │ │ +/// │ │ │ │ +/// └──────────────────────────────────────┘ └─────┘ +/// Focus moved from Content input loses focus +/// topic to content. without topic input gaining it. +/// ``` +/// +/// This state machine offers the following invariants: +/// - When topic input has focus, the status must be [isEditing]. +/// - When content input has focus, the status must be [hasChosen]. +/// - When neither input has focus, and content input was the last +/// input among the two to be focused, the status must be [hasChosen]. +/// - Otherwise, the status must be [notEditingNotChosen]. +enum ComposeTopicInteractionStatus { + /// The topic has likely not been chosen if left empty, + /// and is not being actively edited. + /// + /// When in this status neither the topic input nor the content input has focus. + notEditingNotChosen, + + /// The topic is being actively edited. + /// + /// When in this status, the topic input must have focus. + isEditing, + + /// The topic has likely been chosen, even if it is left empty. + /// + /// When in this status, the topic input must have no focus; + /// the content input might have focus. + hasChosen, +} + class StreamComposeBoxController extends ComposeBoxController { StreamComposeBoxController({required PerAccountStore store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; final topicFocusNode = FocusNode(); + final ValueNotifier topicInteractionStatus = + ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); + + @override void requestFocusIfUnfocused() { + if (topicFocusNode.hasFocus || contentFocusNode.hasFocus) return; + switch (topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + topicFocusNode.requestFocus(); + case ComposeTopicInteractionStatus.isEditing: + // (should be impossible given early-return on topicFocusNode.hasFocus) + break; + case ComposeTopicInteractionStatus.hasChosen: + contentFocusNode.requestFocus(); + } + } @override void dispose() { topic.dispose(); topicFocusNode.dispose(); + topicInteractionStatus.dispose(); super.dispose(); } } class FixedDestinationComposeBoxController extends ComposeBoxController {} +class EditMessageComposeBoxController extends ComposeBoxController { + EditMessageComposeBoxController({ + required this.messageId, + required this.originalRawContent, + required String? initialText, + }) : _content = ComposeContentController( + text: initialText, + // Editing to delete the content is a supported form of + // deletion: https://zulip.com/help/delete-a-message#delete-message-content + requireNotEmpty: false); + + factory EditMessageComposeBoxController.empty(int messageId) => + EditMessageComposeBoxController(messageId: messageId, + originalRawContent: null, initialText: null); + + @override ComposeContentController get content => _content; + final ComposeContentController _content; + + final int messageId; + String? originalRawContent; +} + abstract class _Banner extends StatelessWidget { const _Banner(); @@ -1476,14 +1741,76 @@ class _ErrorBanner extends _Banner { @override Widget? buildTrailing(context) { - // TODO(#720) "x" button goes here. - // 24px square with 8px touchable padding in all directions? - // and `bool get padEnd => false`; see Figma: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev + // An "x" button can go here. + // 24px square with 8px touchable padding in all directions? + // and `bool get padEnd => false`; see Figma: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev return null; } } +class _EditMessageBanner extends _Banner { + const _EditMessageBanner({required this.composeBoxState}); + + final ComposeBoxState composeBoxState; + + @override + String getLabel(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxBannerLabelEditMessage; + + @override + Color getLabelColor(DesignVariables designVariables) => + designVariables.bannerTextIntInfo; + + @override + Color getBackgroundColor(DesignVariables designVariables) => + designVariables.bannerBgIntInfo; + + void _handleTapSave (BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final controller = composeBoxState.controller; + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + + if (controller.content.hasValidationErrors.value) { + final validationErrorMessages = + controller.content.validationErrors.map((error) => + error.message(zulipLocalizations)); + showErrorDialog(context: context, + title: zulipLocalizations.errorMessageEditNotSaved, + message: validationErrorMessages.join('\n\n')); + return; + } + + final originalRawContent = controller.originalRawContent; + if (originalRawContent == null) { + // Fetch-raw-content request hasn't finished; try again later. + // TODO show error dialog? + return; + } + + store.editMessage( + messageId: controller.messageId, + originalRawContent: originalRawContent, + newContent: controller.content.textNormalized); + composeBoxState.endEditInteraction(); + } + + @override + Widget buildTrailing(context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonCancel, + onPressed: composeBoxState.endEditInteraction), + // TODO(#1481) disabled appearance when there are validation errors + // or the original raw content hasn't loaded yet + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonSave, + attention: ZulipWebUiKitButtonAttention.high, + onPressed: () => _handleTapSave(context)), + ]); + } +} + /// The compose box. /// /// Takes the full screen width, covering the horizontal insets with its surface. @@ -1504,6 +1831,7 @@ class ComposeBox extends StatefulWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return false; } } @@ -1515,12 +1843,184 @@ class ComposeBox extends StatefulWidget { /// The interface for the state of a [ComposeBox]. abstract class ComposeBoxState extends State { ComposeBoxController get controller; + + /// Fills the compose box with the content of an [OutboxMessage] + /// for a failed [sendMessage] request. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// [localMessageId], as in [OutboxMessage.localMessageId], must be present + /// in the message store. + void restoreMessageNotSent(int localMessageId); + + /// Switch the compose box to editing mode. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// If called from the message action sheet, fetches the raw message content + /// to fill in the edit-message compose box. + /// + /// If called by tapping a message in the message list with 'EDIT NOT SAVED', + /// fills the edit-message compose box with the content the user wanted + /// in the edit request that failed. + void startEditInteraction(int messageId); + + /// Switch the compose box back to regular non-edit mode, with no content. + void endEditInteraction(); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void restoreMessageNotSent(int localMessageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForOutboxConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + final outboxMessage = store.takeOutboxMessage(localMessageId); + setState(() { + _setNewController(store); + final controller = this.controller; + controller + ..content.value = TextEditingValue(text: outboxMessage.contentMarkdown) + ..contentFocusNode.requestFocus(); + if (controller is StreamComposeBoxController) { + controller.topic.setTopic( + (outboxMessage.conversation as StreamConversation).topic); + } + }); + } + + @override + void startEditInteraction(int messageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForEditConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + + switch (store.getEditMessageErrorStatus(messageId)) { + case null: + _editFromRawContentFetch(messageId); + case true: + _editByRestoringFailedEdit(messageId); + case false: + // This can happen if you start an edit interaction on one + // MessageListPage and then do an edit on a different MessageListPage, + // and the second edit is still saving when you return to the first. + // + // Abort rather than sending a request with a prevContentSha256 + // that the server might not accept, and don't clear the compose + // box, so the user can try again after the request settles. + // TODO could write a test for this + showErrorDialog(context: context, + title: zulipLocalizations.editAlreadyInProgressTitle, + message: zulipLocalizations.editAlreadyInProgressMessage); + return; + } + } + + /// If there's text in the compose box, give a confirmation dialog + /// asking if it can be discarded and await the result. + 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: dialogMessage, + // TODO(#1032) "destructive" style for action button + actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); + if (await dialog.result != true) return true; + } + return false; + } + + void _editByRestoringFailedEdit(int messageId) { + final store = PerAccountStoreWidget.of(context); + // Fill the content input with the content the user wanted in the failed + // edit attempt, not the original content. + // Side effect: Clears the "EDIT NOT SAVED" text in the message list. + final failedEdit = store.takeFailedMessageEdit(messageId); + setState(() { + controller.dispose(); + _controller = EditMessageComposeBoxController( + messageId: messageId, + originalRawContent: failedEdit.originalRawContent, + initialText: failedEdit.newContent, + ) + ..contentFocusNode.requestFocus(); + }); + } + + void _editFromRawContentFetch(int messageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + final emptyEditController = EditMessageComposeBoxController.empty(messageId); + setState(() { + controller.dispose(); + _controller = emptyEditController; + }); + final fetchedRawContent = await ZulipAction.fetchRawContentWithFeedback( + context: context, + messageId: messageId, + errorDialogTitle: zulipLocalizations.errorCouldNotEditMessageTitle, + ); + // TODO timeout this request? + if (!mounted) return; + if (!identical(controller, emptyEditController)) { + // During the fetch-raw-content request, the user tapped Cancel + // or tapped a failed message edit or failed outbox message to restore. + // TODO in this case we don't want the error dialog caused by + // ZulipAction.fetchRawContentWithFeedback; suppress that + return; + } + if (fetchedRawContent == null) { + // Fetch-raw-content failed; abort the edit session. + // An error dialog was already shown, by fetchRawContentWithFeedback. + setState(() { + controller.dispose(); + _setNewController(PerAccountStoreWidget.of(context)); + }); + return; + } + // TODO scroll message list to ensure the message is still in view; + // highlight it? + assert(controller is EditMessageComposeBoxController); + final editMessageController = controller as EditMessageComposeBoxController; + setState(() { + // setState to refresh the input, upload buttons, etc. + // out of the disabled "Preparing…" state. + editMessageController.originalRawContent = fetchedRawContent; + }); + editMessageController.content.value = TextEditingValue(text: fetchedRawContent); + SchedulerBinding.instance.addPostFrameCallback((_) { + // post-frame callback so this happens after the input is enabled + editMessageController.contentFocusNode.requestFocus(); + }); + } + + @override + void endEditInteraction() { + assert(controller is EditMessageComposeBoxController); + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + + final store = PerAccountStoreWidget.of(context); + setState(() { + controller.dispose(); + _setNewController(store); + }); + } + @override void onNewStore() { final newStore = PerAccountStoreWidget.of(context); @@ -1535,6 +2035,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case StreamComposeBoxController(): controller.topic.store = newStore; case FixedDestinationComposeBoxController(): + case EditMessageComposeBoxController(): // no reference to the store that needs updating } } @@ -1549,6 +2050,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): assert(false); } } @@ -1583,6 +2085,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return null; } return null; @@ -1590,13 +2093,15 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override Widget build(BuildContext context) { - final Widget? body; - final errorBanner = _errorBannerComposingNotAllowed(context); if (errorBanner != null) { - return _ComposeBoxContainer(body: null, banner: errorBanner); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: null, banner: errorBanner)); } + final Widget? body; + Widget? banner; + final controller = this.controller; final narrow = widget.narrow; switch (controller) { @@ -1608,13 +2113,47 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM narrow as SendableNarrow; body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } + case EditMessageComposeBoxController(): { + body = _EditMessageComposeBoxBody(controller: controller, narrow: narrow); + banner = _EditMessageBanner(composeBoxState: this); + } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } - return _ComposeBoxContainer(body: body, banner: null); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: body, banner: banner)); + } +} + +/// An [InheritedWidget] to provide data to leafward [StatelessWidget]s, +/// such as flags that should cause the upload buttons to be disabled. +class ComposeBoxInheritedWidget extends InheritedWidget { + factory ComposeBoxInheritedWidget.fromComposeBoxState( + ComposeBoxState state, { + required Widget child, + }) { + final controller = state.controller; + return ComposeBoxInheritedWidget._( + awaitingRawMessageContentForEdit: + controller is EditMessageComposeBoxController + && controller.originalRawContent == null, + child: child, + ); + } + + const ComposeBoxInheritedWidget._({ + required this.awaitingRawMessageContentForEdit, + required super.child, + }); + + final bool awaitingRawMessageContentForEdit; + + @override + bool updateShouldNotify(covariant ComposeBoxInheritedWidget oldWidget) => + awaitingRawMessageContentForEdit != oldWidget.awaitingRawMessageContentForEdit; + + static ComposeBoxInheritedWidget of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + assert(widget != null, 'No ComposeBoxInheritedWidget ancestor'); + return widget!; } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 0305edde91..b49fdb4d9c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -12,9 +12,11 @@ import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/avatar_url.dart'; +import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; import '../model/katex.dart'; +import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; @@ -26,6 +28,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). /// @@ -651,6 +654,7 @@ class MessageImage extends StatelessWidget { Navigator.of(context).push(getImageLightboxRoute( context: context, message: message, + messageImageContext: context, src: resolvedSrcUrl, thumbnailUrl: resolvedThumbnailUrl, originalWidth: node.originalWidth, @@ -659,7 +663,7 @@ class MessageImage extends StatelessWidget { child: node.loading ? const CupertinoActivityIndicator() : resolvedSrcUrl == null ? null : LightboxHero( - message: message, + messageImageContext: context, src: resolvedSrcUrl, child: RealmContentNetworkImage( resolvedThumbnailUrl ?? resolvedSrcUrl, @@ -868,7 +872,9 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + }); })))); } } @@ -876,7 +882,7 @@ class _KatexNodeList extends StatelessWidget { class _KatexSpan extends StatelessWidget { const _KatexSpan(this.node); - final KatexNode node; + final KatexSpanNode node; @override Widget build(BuildContext context) { @@ -939,7 +945,12 @@ class _KatexSpan extends StatelessWidget { textAlign: textAlign, child: widget); } - return widget; + + return SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * (fontSize ?? em) + : null, + child: widget); } } @@ -987,7 +998,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), @@ -1534,15 +1545,18 @@ void _launchUrl(BuildContext context, String urlString) async { return; } - final internalNarrow = parseInternalLink(url, store); - if (internalNarrow != null) { - unawaited(Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: internalNarrow))); - return; + final internalLink = parseInternalLink(url, store); + assert(internalLink == null || internalLink.realmUrl == store.realmUrl); + switch (internalLink) { + case NarrowLink(): + unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: internalLink.narrow, + initAnchorMessageId: internalLink.nearMessageId))); + + case null: + await PlatformActions.launchUrl(context, url); } - - await PlatformActions.launchUrl(context, url); } /// Like [Image.network], but includes [authHeader] if [src] is on-realm. @@ -1657,18 +1671,29 @@ class Avatar extends StatelessWidget { required this.userId, required this.size, required this.borderRadius, + this.backgroundColor, + this.showPresence = true, + this.replaceIfMuted = true, }); final int userId; final double size; final double borderRadius; + final Color? backgroundColor; + final bool showPresence; + final bool replaceIfMuted; @override Widget build(BuildContext context) { + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || showPresence); return AvatarShape( size: size, borderRadius: borderRadius, - child: AvatarImage(userId: userId, size: size)); + backgroundColor: backgroundColor, + userIdForPresence: showPresence ? userId : null, + child: AvatarImage(userId: userId, size: size, replaceIfMuted: replaceIfMuted)); } } @@ -1682,10 +1707,12 @@ class AvatarImage extends StatelessWidget { super.key, required this.userId, required this.size, + this.replaceIfMuted = true, }); final int userId; final double size; + final bool replaceIfMuted; @override Widget build(BuildContext context) { @@ -1696,6 +1723,10 @@ class AvatarImage extends StatelessWidget { return const SizedBox.shrink(); } + if (replaceIfMuted && store.isUserMuted(userId)) { + return _AvatarPlaceholder(size: size); + } + final resolvedUrl = switch (user.avatarUrl) { null => null, // TODO(#255): handle computing gravatars var avatarUrl => store.tryResolveUrl(avatarUrl), @@ -1716,27 +1747,196 @@ class AvatarImage extends StatelessWidget { } } +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +// TODO(#1558) use this as a fallback in more places (?) and update dartdoc. +class _AvatarPlaceholder extends StatelessWidget { + const _AvatarPlaceholder({required this.size}); + + /// The size of the placeholder box. + /// + /// This should match the `size` passed to the wrapping [AvatarShape]. + /// The placeholder's icon will be scaled proportionally to this. + final double size; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration(color: designVariables.avatarPlaceholderBg), + child: Icon(ZulipIcons.person, + // Where the avatar placeholder appears in the Figma, + // this is how the icon is sized proportionally to its box. + size: size * 20 / 32, + color: designVariables.avatarPlaceholderIcon)); + } +} + /// A rounded square shape, to wrap an [AvatarImage] or similar. +/// +/// If [userIdForPresence] is provided, this will paint a [PresenceCircle] +/// on the shape. class AvatarShape extends StatelessWidget { const AvatarShape({ super.key, required this.size, required this.borderRadius, + this.backgroundColor, + this.userIdForPresence, required this.child, }); final double size; final double borderRadius; + final Color? backgroundColor; + final int? userIdForPresence; final Widget child; @override Widget build(BuildContext context) { - return SizedBox.square( + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || userIdForPresence != null); + + Widget result = SizedBox.square( dimension: size, child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(borderRadius)), clipBehavior: Clip.antiAlias, child: child)); + + if (userIdForPresence != null) { + final presenceCircleSize = size / 4; // TODO(design) is this right? + result = Stack(children: [ + result, + Positioned.directional(textDirection: Directionality.of(context), + end: 0, + bottom: 0, + child: PresenceCircle( + userId: userIdForPresence!, + size: presenceCircleSize, + backgroundColor: backgroundColor)), + ]); + } + + return result; + } +} + +/// The green or orange-gradient circle representing [PresenceStatus]. +/// +/// [backgroundColor] must not be [Colors.transparent]. +/// It exists to match the background on which the avatar image is painted. +/// If [backgroundColor] is not passed, [DesignVariables.mainBackground] is used. +/// +/// By default, nothing paints for a user in the "offline" status +/// (i.e. a user without a [PresenceStatus]). +/// Pass true for [explicitOffline] to paint a gray circle. +class PresenceCircle extends StatefulWidget { + const PresenceCircle({ + super.key, + required this.userId, + required this.size, + this.backgroundColor, + this.explicitOffline = false, + }); + + final int userId; + final double size; + final Color? backgroundColor; + final bool explicitOffline; + + /// Creates a [WidgetSpan] with a [PresenceCircle], for use in rich text + /// before a user's name. + /// + /// The [PresenceCircle] will have `explicitOffline: true`. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + Color? backgroundColor, + }) { + final size = textScaler.scale(fontSize) / 2; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: PresenceCircle( + userId: userId, + size: size, + backgroundColor: backgroundColor, + explicitOffline: true))); + } + + @override + State createState() => _PresenceCircleState(); +} + +class _PresenceCircleState extends State with PerAccountStoreAwareStateMixin { + Presence? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).presence + ..addListener(_modelChanged); + } + + @override + void dispose() { + model!.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + @override + Widget build(BuildContext context) { + final status = model!.presenceStatusForUser( + widget.userId, utcNow: ZulipBinding.instance.utcNow()); + final designVariables = DesignVariables.of(context); + final effectiveBackgroundColor = widget.backgroundColor ?? designVariables.mainBackground; + assert(effectiveBackgroundColor != Colors.transparent); + + Color? color; + LinearGradient? gradient; + switch (status) { + case null: + if (widget.explicitOffline) { + // TODO(a11y) this should be an open circle, like on web, + // to differentiate by shape (vs. the "active" status which is also + // a solid circle) + color = designVariables.statusAway; + } else { + return SizedBox.square(dimension: widget.size); + } + case PresenceStatus.active: + color = designVariables.statusOnline; + case PresenceStatus.idle: + gradient = LinearGradient( + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + colors: [designVariables.statusIdle, effectiveBackgroundColor], + stops: [0.05, 1.00], + ); + } + + return SizedBox.square(dimension: widget.size, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: effectiveBackgroundColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside), + color: color, + gradient: gradient, + shape: BoxShape.circle))); } } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 0d2b4c5a7d..e635071cc9 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/settings.dart'; import 'actions.dart'; +import 'app.dart'; +import 'content.dart'; +import 'store.dart'; Widget _dialogActionText(String text) { return Text( @@ -49,10 +53,15 @@ class DialogStatus { /// /// The [DialogStatus.result] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// Prose in [message] should have final punctuation: +/// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 +/// +/// The context argument should be a descendant of the app's main [Navigator]. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when -// intepreting the meaning of the [Future]. +// interpreting the meaning of the [Future]. DialogStatus showErrorDialog({ required BuildContext context, required String title, @@ -83,6 +92,8 @@ DialogStatus showErrorDialog({ /// If the dialog was canceled, /// either with the cancel button or by tapping outside the dialog's area, /// it completes with null. +/// +/// The context argument should be a descendant of the app's main [Navigator]. DialogStatus showSuggestedActionDialog({ required BuildContext context, required String title, @@ -105,3 +116,69 @@ DialogStatus showSuggestedActionDialog({ ])); return DialogStatus(future); } + +/// A brief dialog box welcoming the user to this new Zulip app, +/// shown upon upgrading from the legacy app. +class UpgradeWelcomeDialog extends StatelessWidget { + const UpgradeWelcomeDialog._(); + + static void maybeShow() async { + final navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final globalSettings = GlobalStoreWidget.settingsOf(context); + switch (globalSettings.legacyUpgradeState) { + case LegacyUpgradeState.noLegacy: + // This install didn't replace the legacy app. + return; + + case LegacyUpgradeState.unknown: + // Not clear if this replaced the legacy app; + // skip the dialog that would assume it had. + // TODO(log) + return; + + case LegacyUpgradeState.found: + case LegacyUpgradeState.migrated: + // This install replaced the legacy app. + // Show the dialog, if we haven't already. + if (globalSettings.getBool(BoolGlobalSetting.upgradeWelcomeDialogShown)) { + return; + } + } + + final future = showDialog( + context: context, + builder: (context) => UpgradeWelcomeDialog._()); + + await future; // Wait for the dialog to be dismissed. + + await globalSettings.setBool(BoolGlobalSetting.upgradeWelcomeDialogShown, true); + } + + static const String _announcementUrl = + 'https://blog.zulip.com/flutter-mobile-app-launch'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return AlertDialog( + title: Text(zulipLocalizations.upgradeWelcomeDialogTitle), + content: SingleChildScrollView( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(zulipLocalizations.upgradeWelcomeDialogMessage), + GestureDetector( + onTap: () => PlatformActions.launchUrl(context, + Uri.parse(_announcementUrl)), + child: Text( + style: TextStyle(color: ContentTheme.of(context).colorLink), + zulipLocalizations.upgradeWelcomeDialogLinkText)), + ])), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), + child: Text(zulipLocalizations.upgradeWelcomeDialogDismiss)), + ]); + } +} diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 0f6d490a97..1f5dc557ec 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -330,7 +330,7 @@ class _ImageEmoji extends StatelessWidget { // Unicode and text emoji get scaled; it would look weird if image emoji didn't. textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay, - errorBuilder: (context, _, __) => _TextEmoji( + errorBuilder: (context, _, _) => _TextEmoji( emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), ); } diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ab5ad446db..dd269e03f7 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -111,7 +111,7 @@ class _HomePageState extends State { narrow: const CombinedFeedNarrow()))), button(_HomePageTab.channels, ZulipIcons.hash_italic), // TODO(#1094): Users - button(_HomePageTab.directMessages, ZulipIcons.user), + button(_HomePageTab.directMessages, ZulipIcons.two_person), _NavigationBarButton( icon: ZulipIcons.menu, selected: false, onPressed: () => _showMainMenu(context, tabNotifier: _tab)), @@ -267,7 +267,7 @@ void _showMainMenu(BuildContext context, { required ValueNotifier<_HomePageTab> tabNotifier, }) { final menuItems = [ - // TODO(#252): Search + const _SearchButton(), // const SizedBox(height: 8), _InboxButton(tabNotifier: tabNotifier), // TODO: Recent conversations @@ -427,6 +427,24 @@ abstract class _NavigationBarMenuButton extends _MenuButton { } } +class _SearchButton extends _MenuButton { + const _SearchButton(); + + @override + IconData get icon => ZulipIcons.search; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.searchMessagesPageTitle; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MessageListPage.buildRoute( + context: context, narrow: KeywordSearchNarrow(''))); + } +} + class _InboxButton extends _NavigationBarMenuButton { const _InboxButton({required super.tabNotifier}); @@ -515,7 +533,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { const _DirectMessagesButton({required super.tabNotifier}); @override - IconData get icon => ZulipIcons.user; + IconData get icon => ZulipIcons.two_person; @override String label(ZulipLocalizations zulipLocalizations) { @@ -536,7 +554,11 @@ class _MyProfileButton extends _MenuButton { Widget buildLeading(BuildContext context) { final store = PerAccountStoreWidget.of(context); return Avatar( - userId: store.selfUserId, size: _MenuButton._iconSize, borderRadius: 4); + userId: store.selfUserId, + size: _MenuButton._iconSize, + borderRadius: 4, + showPresence: false, + ); } @override diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index bab7b152de..1b5c424b0b 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,107 +48,134 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check". static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_circle_checked". + static const IconData check_circle_checked = IconData(0xf109, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_circle_unchecked". + static const IconData check_circle_unchecked = IconData(0xf10a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_remove". - static const IconData check_remove = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye". + static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye_off". + static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "person". + static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "plus". + static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "remove". + static const IconData remove = IconData(0xf126, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "search". + static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "topics". + static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". + static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "unmute". + static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index a8c0c12b59..d00cabb9dc 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -8,6 +8,7 @@ import '../model/unreads.dart'; import 'action_sheet.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; @@ -82,6 +83,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final subscriptions = store.subscriptions; @@ -160,6 +162,12 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } + if (sections.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#315) add e.g. "You might be interested in recent conversations." + message: zulipLocalizations.inboxEmptyPlaceholder); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. bottom: false, @@ -319,7 +327,7 @@ class _AllDmsHeaderItem extends _HeaderItem { @override String title(ZulipLocalizations zulipLocalizations) => zulipLocalizations.recentDmConversationsSectionHeader; - @override IconData get icon => ZulipIcons.user; + @override IconData get icon => ZulipIcons.two_person; // TODO(design) check if this is the right variable for these @override Color collapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; @@ -387,6 +395,7 @@ class _DmItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + // TODO write a test where a/the recipient is muted final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] [] => store.selfUser.fullName, [var otherUserId] => store.userDisplayName(otherUserId), @@ -546,14 +555,12 @@ class _TopicItem extends StatelessWidget { style: TextStyle( fontSize: 17, height: (20 / 17), - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, // TODO(design) check if this is the right variable color: designVariables.labelMenuButton, ), maxLines: 2, overflow: TextOverflow.ellipsis, - // ignore: dead_null_aware_expression // null topic names soon to be enabled topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index a66e4137dd..7199c72a5c 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -15,45 +15,67 @@ import 'dialog.dart'; import 'page.dart'; import 'store.dart'; -// TODO(#44): Add index of the image preview in the message, to not break if -// there are multiple image previews with the same URL in the same -// message. Maybe keep `src`, so that on exit the lightbox image doesn't -// fly to an image preview with a different URL, following a message edit -// while the lightbox was open. +/// Identifies which [LightboxHero]s should match up with each other +/// to produce a hero animation. +/// +/// See [Hero.tag], the field where we use instances of this class. +/// +/// The intended behavior is that when the user acts on an image +/// in the message list to have the app expand it in the lightbox, +/// a hero animation goes from the original view of the image +/// to the version in the lightbox, +/// and back to the original upon exiting the lightbox. class _LightboxHeroTag { - _LightboxHeroTag({required this.messageId, required this.src}); + _LightboxHeroTag({ + required this.messageImageContext, + required this.src, + }); - final int messageId; + /// The [BuildContext] for the [MessageImage] being expanded into the lightbox. + /// + /// In particular this prevents hero animations between + /// different message lists that happen to have the same message. + /// It also distinguishes different copies of the same image + /// in a given message list. + // TODO: write a regression test for #44, duplicate images within a message + final BuildContext messageImageContext; + + /// The image source URL. + /// + /// This ensures the animation only occurs between matching images, even if + /// the message was edited before navigating back to the message list + /// so that the original [MessageImage] has been replaced in the tree + /// by a different image. final Uri src; @override bool operator ==(Object other) { return other is _LightboxHeroTag && - other.messageId == messageId && + other.messageImageContext == messageImageContext && other.src == src; } @override - int get hashCode => Object.hash('_LightboxHeroTag', messageId, src); + int get hashCode => Object.hash('_LightboxHeroTag', messageImageContext, src); } /// Builds a [Hero] from an image in the message list to the lightbox page. class LightboxHero extends StatelessWidget { const LightboxHero({ super.key, - required this.message, + required this.messageImageContext, required this.src, required this.child, }); - final Message message; + final BuildContext messageImageContext; final Uri src; final Widget child; @override Widget build(BuildContext context) { return Hero( - tag: _LightboxHeroTag(messageId: message.id, src: src), + tag: _LightboxHeroTag(messageImageContext: messageImageContext, src: src), flightShuttleBuilder: ( BuildContext flightContext, Animation animation, @@ -144,6 +166,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); final appBarBackgroundColor = Colors.grey.shade900.withValues(alpha: 0.87); @@ -172,13 +195,19 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { shape: const Border(), // Remove bottom border from [AppBarTheme] elevation: appBarElevation, title: Row(children: [ - Avatar(size: 36, borderRadius: 36 / 8, userId: widget.message.senderId), + Avatar( + size: 36, + borderRadius: 36 / 8, + userId: widget.message.senderId, + replaceIfMuted: false, + ), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan(children: [ TextSpan( - text: '${widget.message.senderFullName}\n', // TODO(#716): use `store.senderDisplayName` + // TODO write a test where the sender is muted; check this and avatar + text: '${store.senderDisplayName(widget.message, replaceIfMuted: false)}\n', // Restate default style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), @@ -226,6 +255,7 @@ class _ImageLightboxPage extends StatefulWidget { const _ImageLightboxPage({ required this.routeEntranceAnimation, required this.message, + required this.messageImageContext, required this.src, required this.thumbnailUrl, required this.originalWidth, @@ -234,6 +264,7 @@ class _ImageLightboxPage extends StatefulWidget { final Animation routeEntranceAnimation; final Message message; + final BuildContext messageImageContext; final Uri src; final Uri? thumbnailUrl; final double? originalWidth; @@ -317,7 +348,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { child: InteractiveViewer( child: SafeArea( child: LightboxHero( - message: widget.message, + messageImageContext: widget.messageImageContext, src: widget.src, child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium, @@ -599,6 +630,7 @@ Route getImageLightboxRoute({ int? accountId, BuildContext? context, required Message message, + required BuildContext messageImageContext, required Uri src, required Uri? thumbnailUrl, required double? originalWidth, @@ -611,6 +643,7 @@ Route getImageLightboxRoute({ return _ImageLightboxPage( routeEntranceAnimation: animation, message: message, + messageImageContext: messageImageContext, src: src, thumbnailUrl: thumbnailUrl, originalWidth: originalWidth, diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 90a0762d34..34dcc2bf2c 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; @@ -5,6 +7,8 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/database.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -12,6 +16,7 @@ import '../model/typing_status.dart'; import 'action_sheet.dart'; import 'actions.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; @@ -24,11 +29,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(), @@ -43,13 +48,9 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.white.withValues(alpha: 0.6), - - // TODO(design) this seems ad-hoc; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), ); 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,20 +64,15 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.transparent, - - // TODO(design) this is ad-hoc and untested; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), ); MessageListTheme._({ - required this.bgMessageRegular, required this.dmRecipientHeaderBg, required this.labelTime, required this.senderBotIcon, required this.streamRecipientHeaderChevronRight, required this.unreadMarker, required this.unreadMarkerGap, - required this.unsubscribedStreamRecipientHeaderBg, }); /// The [MessageListTheme] from the context's active theme. @@ -89,35 +85,29 @@ class MessageListTheme extends ThemeExtension { return extension!; } - final Color bgMessageRegular; final Color dmRecipientHeaderBg; final Color labelTime; final Color senderBotIcon; final Color streamRecipientHeaderChevronRight; final Color unreadMarker; final Color unreadMarkerGap; - final Color unsubscribedStreamRecipientHeaderBg; @override MessageListTheme copyWith({ - Color? bgMessageRegular, Color? dmRecipientHeaderBg, Color? labelTime, Color? senderBotIcon, Color? streamRecipientHeaderChevronRight, Color? unreadMarker, Color? unreadMarkerGap, - Color? unsubscribedStreamRecipientHeaderBg, }) { return MessageListTheme._( - bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg, labelTime: labelTime ?? this.labelTime, senderBotIcon: senderBotIcon ?? this.senderBotIcon, streamRecipientHeaderChevronRight: streamRecipientHeaderChevronRight ?? this.streamRecipientHeaderChevronRight, unreadMarker: unreadMarker ?? this.unreadMarker, unreadMarkerGap: unreadMarkerGap ?? this.unreadMarkerGap, - unsubscribedStreamRecipientHeaderBg: unsubscribedStreamRecipientHeaderBg ?? this.unsubscribedStreamRecipientHeaderBg, ); } @@ -127,14 +117,12 @@ 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)!, streamRecipientHeaderChevronRight: Color.lerp(streamRecipientHeaderChevronRight, other.streamRecipientHeaderChevronRight, t)!, unreadMarker: Color.lerp(unreadMarker, other.unreadMarker, t)!, unreadMarkerGap: Color.lerp(unreadMarkerGap, other.unreadMarkerGap, t)!, - unsubscribedStreamRecipientHeaderBg: Color.lerp(unsubscribedStreamRecipientHeaderBg, other.unsubscribedStreamRecipientHeaderBg, t)!, ); } } @@ -155,15 +143,51 @@ abstract class MessageListPageState { /// /// This is null if [MessageList] has not mounted yet. MessageListView? get model; + + /// This view's decision whether to mark read on scroll, + /// overriding [GlobalSettings.markReadOnScroll]. + /// + /// For example, this is set to false after pressing + /// "Mark as unread from here" in the message action sheet. + bool? get markReadOnScroll; + set markReadOnScroll(bool? value); + + /// For a message from a muted sender, reveal the sender and content, + /// replacing the "Muted user" placeholder. + void revealMutedMessage(int messageId); + + /// For a message from a muted sender, hide the sender and content again + /// with the "Muted user" placeholder. + void unrevealMutedMessage(int messageId); } class MessageListPage extends StatefulWidget { - const MessageListPage({super.key, required this.initNarrow}); + const MessageListPage({ + super.key, + required this.initNarrow, + this.initAnchorMessageId, + }); static AccountRoute buildRoute({int? accountId, BuildContext? context, - required Narrow narrow}) { + required Narrow narrow, int? initAnchorMessageId}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, - page: MessageListPage(initNarrow: narrow)); + page: MessageListPage( + initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); + } + + /// The "revealed" state of a message from a muted sender. + /// + /// This is updated via [MessageListPageState.revealMutedMessage] + /// and [MessageListPageState.unrevealMutedMessage]. + /// + /// Uses the efficient [BuildContext.dependOnInheritedWidgetOfExactType], + /// so this is safe to call in a build method. + static RevealedMutedMessagesState revealedMutedMessagesOf(BuildContext context) { + final state = + context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>() + ?.state; + assert(state != null, 'No MessageListPage ancestor'); + return state!; } /// The [MessageListPageState] above this context in the tree. @@ -179,9 +203,36 @@ class MessageListPage extends StatefulWidget { } final Narrow initNarrow; + final int? initAnchorMessageId; // TODO(#1564) highlight target upon load @override State createState() => _MessageListPageState(); + + /// In debug mode, controls whether mark-read-on-scroll is enabled, + /// overriding [GlobalSettings.markReadOnScroll] + /// and [MessageListPageState.markReadOnScroll]. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnableMarkReadOnScroll { + bool result = true; + assert(() { + result = _debugEnableMarkReadOnScroll; + return true; + }()); + return result; + } + static bool _debugEnableMarkReadOnScroll = true; + static set debugEnableMarkReadOnScroll(bool value) { + assert(() { + _debugEnableMarkReadOnScroll = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugEnableMarkReadOnScroll = true; + } } class _MessageListPageState extends State implements MessageListPageState { @@ -196,6 +247,28 @@ class _MessageListPageState extends State implements MessageLis MessageListView? get model => _messageListKey.currentState?.model; final GlobalKey<_MessageListState> _messageListKey = GlobalKey(); + @override + bool? get markReadOnScroll => _markReadOnScroll; + bool? _markReadOnScroll; + @override + set markReadOnScroll(bool? value) { + setState(() { + _markReadOnScroll = value; + }); + } + + final _revealedMutedMessages = RevealedMutedMessagesState(); + + @override + void revealMutedMessage(int messageId) { + _revealedMutedMessages._add(messageId); + } + + @override + void unrevealMutedMessage(int messageId) { + _revealedMutedMessages._remove(messageId); + } + @override void initState() { super.initState(); @@ -210,57 +283,19 @@ class _MessageListPageState extends State implements MessageLis @override Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final messageListTheme = MessageListTheme.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - - final Color? appBarBackgroundColor; - bool removeAppBarBottomBorder = false; - switch(narrow) { - case CombinedFeedNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): - appBarBackgroundColor = null; // i.e., inherit - - case ChannelNarrow(:final streamId): - case TopicNarrow(:final streamId): - final subscription = store.subscriptions[streamId]; - appBarBackgroundColor = subscription != null - ? colorSwatchFor(context, subscription).barBackground - : messageListTheme.unsubscribedStreamRecipientHeaderBg; - // All recipient headers will match this color; remove distracting line - // (but are recipient headers even needed for topic narrows?) - removeAppBarBottomBorder = true; - - case DmNarrow(): - appBarBackgroundColor = messageListTheme.dmRecipientHeaderBg; - // All recipient headers will match this color; remove distracting line - // (but are recipient headers even needed?) - 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))))); + final Anchor initAnchor; + if (narrow is KeywordSearchNarrow) { + initAnchor = AnchorCode.newest; + } else if (widget.initAnchorMessageId != null) { + initAnchor = NumericAnchor(widget.initAnchorMessageId!); + } else { + final globalSettings = GlobalStoreWidget.settingsOf(context); + final useFirstUnread = globalSettings.shouldVisitFirstUnread(narrow: narrow); + initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest; } - // Insert a PageRoot here, to provide a context that can be used for - // MessageListPage.ancestorOf. - return PageRoot(child: Scaffold( - appBar: ZulipAppBar( - buildTitle: (willCenterTitle) => - MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), - actions: actions, - backgroundColor: appBarBackgroundColor, - shape: removeAppBarBottomBorder - ? const Border() - : null, // i.e., inherit - ), + Widget result = Scaffold( + appBar: _MessageListAppBar.build(context, narrow: narrow), // TODO question for Vlad: for a stream view, should we set the Scaffold's // [backgroundColor] based on stream color, as in this frame: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev @@ -268,7 +303,8 @@ class _MessageListPageState extends State implements MessageLis // we matched to the Figma in 21dbae120. See another frame, which uses that: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev body: Builder( - builder: (BuildContext context) => Column( + builder: (BuildContext context) { + return Column( // Children are expected to take the full horizontal space // and handle the horizontal device insets. // The bottom inset should be handled by the last child only. @@ -288,11 +324,159 @@ class _MessageListPageState extends State implements MessageLis child: MessageList( key: _messageListKey, narrow: narrow, + initAnchor: initAnchor, onNarrowChanged: _narrowChanged, + markReadOnScroll: markReadOnScroll, ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) - ])))); + ]); + })); + + // Insert a PageRoot here (under MessageListPage), + // to provide a context that can be used for MessageListPage.ancestorOf. + result = PageRoot(child: result); + + result = _RevealedMutedMessagesProvider(state: _revealedMutedMessages, + child: result); + + return result; + } +} + +// Conceptually this should be a widget class. But it needs to be a +// PreferredSizeWidget, with the `preferredSize` that the underlying AppBar +// will have... and there's currently no good way to get that value short of +// constructing the whole AppBar widget with all its properties. +// So this has to be built eagerly by its parent's build method, +// making it a build function rather than a widget. Discussion: +// https://github.com/zulip/zulip-flutter/pull/1662#discussion_r2183471883 +// Still we can organize it on a class, with the name the widget would have. +// TODO(upstream): AppBar should expose a bit more API so that it's possible +// to customize by composition in a reasonable way. +abstract class _MessageListAppBar { + static AppBar build(BuildContext context, {required Narrow narrow}) { + final store = PerAccountStoreWidget.of(context); + final messageListTheme = MessageListTheme.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final Color? appBarBackgroundColor; + bool removeAppBarBottomBorder = false; + switch(narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + appBarBackgroundColor = null; // i.e., inherit + + case ChannelNarrow(:final streamId): + case TopicNarrow(:final streamId): + final subscription = store.subscriptions[streamId]; + appBarBackgroundColor = + colorSwatchFor(context, subscription).barBackground; + // All recipient headers will match this color; remove distracting line + // (but are recipient headers even needed for topic narrows?) + removeAppBarBottomBorder = true; + + case DmNarrow(): + appBarBackgroundColor = messageListTheme.dmRecipientHeaderBg; + // All recipient headers will match this color; remove distracting line + // (but are recipient headers even needed?) + removeAppBarBottomBorder = true; + } + + List actions = []; + switch (narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + 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)); + } + + return ZulipAppBar( + centerTitle: switch (narrow) { + CombinedFeedNarrow() || ChannelNarrow() + || TopicNarrow() || DmNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + => null, + KeywordSearchNarrow() + => false, + }, + buildTitle: (willCenterTitle) => + MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), + actions: actions, + backgroundColor: appBarBackgroundColor, + shape: removeAppBarBottomBorder + ? const Border() + : null, // i.e., inherit + ); + } +} + +class RevealedMutedMessagesState extends ChangeNotifier { + final Set _revealedMessages = {}; + + bool isMutedMessageRevealed(int messageId) => + _revealedMessages.contains(messageId); + + void _add(int messageId) { + _revealedMessages.add(messageId); + notifyListeners(); + } + + void _remove(int messageId) { + _revealedMessages.remove(messageId); + notifyListeners(); + } +} + +class _RevealedMutedMessagesProvider extends InheritedNotifier { + const _RevealedMutedMessagesProvider({ + required RevealedMutedMessagesState state, + required super.child, + }) : super(notifier: state); + + RevealedMutedMessagesState get state => notifier!; +} + +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)))))); } } @@ -309,9 +493,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. @@ -319,7 +512,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)), @@ -338,10 +531,8 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - // ignore: dead_null_aware_expression // null topic names soon to be enabled Flexible(child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, style: TextStyle( fontSize: 13, - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, ).merge(weightVariableTextStyle(context)))), if (icon != null) @@ -408,7 +599,7 @@ class MessageListAppBarTitle extends StatelessWidget { // either still fetching messages (and the user can reopen the // sheet after that finishes) or there aren't any messages to // act on anyway. - assert(someMessage == null || narrow.containsMessage(someMessage)); + assert(someMessage == null || narrow.containsMessage(someMessage)!); showTopicActionSheet(context, channelId: streamId, topic: topic, @@ -428,10 +619,102 @@ class MessageListAppBarTitle extends StatelessWidget { return Text( zulipLocalizations.dmsWithOthersPageTitle(names.join(', '))); } + + case KeywordSearchNarrow(): + assert(!willCenterTitle); + return _SearchBar(onSubmitted: (narrow) { + MessageListPage.ancestorOf(context).model!.renarrowAndFetch(narrow); + }); } } } +class _SearchBar extends StatefulWidget { + const _SearchBar({required this.onSubmitted}); + + final void Function(KeywordSearchNarrow) onSubmitted; + + @override + State<_SearchBar> createState() => _SearchBarState(); +} + +class _SearchBarState extends State<_SearchBar> { + late TextEditingController _controller; + + static KeywordSearchNarrow _valueToNarrow(String value) => + KeywordSearchNarrow(value.trim()); + + @override + void initState() { + _controller = TextEditingController(); + super.initState(); + } + + void _handleSubmitted(String value) { + widget.onSubmitted(_valueToNarrow(value)); + } + + void _clearInput() { + _controller.clear(); + _handleSubmitted(''); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return TextField( + controller: _controller, + autocorrect: false, + + // Servers as of 2025-07 seem to require straight quotes for the + // "exact match"- style query. (N.B. the doc says this param is iOS-only.) + smartQuotesType: SmartQuotesType.disabled, + + autofocus: true, + onSubmitted: _handleSubmitted, + cursorColor: designVariables.textInput, + style: TextStyle( + color: designVariables.textInput, + fontSize: 19, + height: 28 / 19, + ), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + hintText: zulipLocalizations.searchMessagesHintText, + hintStyle: TextStyle(color: designVariables.labelSearchPrompt), + prefixIcon: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 8, 0, 8), + child: Icon(size: 24, ZulipIcons.search)), + prefixIconColor: designVariables.labelSearchPrompt, + prefixIconConstraints: BoxConstraints(), + suffixIcon: IconButton( + tooltip: zulipLocalizations.searchMessagesClearButtonTooltip, + onPressed: _clearInput, + // This and `suffixIconConstraints` allow 42px square touch target. + visualDensity: VisualDensity.compact, + highlightColor: Colors.transparent, + style: ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.zero), + splashFactory: NoSplash.splashFactory, + ), + iconSize: 24, + icon: Icon(ZulipIcons.remove)), + suffixIconColor: designVariables.textMessageMuted, + suffixIconConstraints: BoxConstraints(minWidth: 42, minHeight: 42), + contentPadding: const EdgeInsetsDirectional.symmetric(vertical: 7), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + )); + } +} + + /// The approximate height of a short message in the message list. const _kShortMessageHeight = 80; @@ -452,18 +735,31 @@ const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMes /// When there is no [ComposeBox], also takes responsibility /// for dealing with the bottom inset. class MessageList extends StatefulWidget { - const MessageList({super.key, required this.narrow, required this.onNarrowChanged}); + const MessageList({ + super.key, + required this.narrow, + required this.initAnchor, + required this.onNarrowChanged, + required this.markReadOnScroll, + }); final Narrow narrow; + final Anchor initAnchor; final void Function(Narrow newNarrow) onNarrowChanged; + final bool? markReadOnScroll; @override State createState() => _MessageListState(); } class _MessageListState extends State with PerAccountStoreAwareStateMixin { - MessageListView? model; + final GlobalKey _scrollViewKey = GlobalKey(); + + MessageListView get model => _model!; + MessageListView? _model; + final MessageListScrollController scrollController = MessageListScrollController(); + final ValueNotifier _scrollToBottomVisible = ValueNotifier(false); @override @@ -474,40 +770,178 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages - model?.dispose(); - _initModel(PerAccountStoreWidget.of(context)); + final anchor = _model == null ? widget.initAnchor : _model!.anchor; + _model?.dispose(); + _initModel(PerAccountStoreWidget.of(context), anchor); } @override void dispose() { - model?.dispose(); + _model?.dispose(); scrollController.dispose(); _scrollToBottomVisible.dispose(); super.dispose(); } - void _initModel(PerAccountStore store) { - model = MessageListView.init(store: store, narrow: widget.narrow); - model!.addListener(_modelChanged); - model!.fetchInitial(); + void _initModel(PerAccountStore store, Anchor anchor) { + _model = MessageListView.init(store: store, + narrow: widget.narrow, anchor: anchor); + model.addListener(_modelChanged); + model.fetchInitial(); } + bool _prevFetched = false; + void _modelChanged() { - if (model!.narrow != widget.narrow) { + // When you're scrolling quickly, our mark-as-read requests include the + // messages *between* _messagesRecentlyInViewport and the messages currently + // in view, so that messages don't get left out because you were scrolling + // so fast that they never rendered onscreen. + // + // Here, the onscreen messages might be totally different, + // and not because of scrolling; e.g. because the narrow changed. + // Avoid "filling in" a mark-as-read request with totally wrong messages, + // by forgetting the old range. + _messagesRecentlyInViewport = null; + + if (model.narrow != widget.narrow) { // Either: // - A message move event occurred, where propagate mode is // [PropagateMode.changeAll] or [PropagateMode.changeLater]. Or: // - We fetched a "with" / topic-permalink narrow, and the response // redirected us to the new location of the operand message ID. - widget.onNarrowChanged(model!.narrow); + widget.onNarrowChanged(model.narrow); } + // TODO when model reset, reset scroll setState(() { // The actual state lives in the [MessageListView] model. // This method was called because that just changed. }); + + if (!_prevFetched && model.fetched && model.messages.isEmpty) { + // If the fetch came up empty, there's nothing to read, + // so opening the keyboard won't be bothersome and could be helpful. + // It's definitely helpful if we got here from the new-DM page. + MessageListPage.ancestorOf(context) + .composeBoxState?.controller.requestFocusIfUnfocused(); + } + _prevFetched = model.fetched; + } + + /// Find the range of message IDs on screen, as a (first, last) tuple, + /// or null if no messages are onscreen. + /// + /// A message is considered onscreen if its bottom edge is in the viewport. + /// + /// Ignores outbox messages. + (int, int)? _findMessagesInViewport() { + final scrollViewElement = _scrollViewKey.currentContext as Element; + final scrollViewRenderObject = scrollViewElement.renderObject as RenderBox; + + int? first; + int? last; + void visit(Element element) { + final widget = element.widget; + switch (widget) { + case RecipientHeader(): + case DateSeparator(): + case MarkAsReadWidget(): + // MessageItems won't be descendants of these + return; + + case MessageItem(item: MessageListOutboxMessageItem()): + return; // ignore outbox + + case MessageItem(item: MessageListMessageItem(:final message)): + final isInViewport = _isMessageItemInViewport( + element, scrollViewRenderObject: scrollViewRenderObject); + if (isInViewport) { + if (first == null) { + assert(last == null); + first = message.id; + last = message.id; + return; + } + if (message.id < first!) { + first = message.id; + } + if (last! < message.id) { + last = message.id; + } + } + return; // no need to look for more MessageItems inside this one + + default: + element.visitChildElements(visit); + } + } + scrollViewElement.visitChildElements(visit); + + if (first == null) { + assert(last == null); + return null; + } + return (first!, last!); + } + + bool _isMessageItemInViewport( + Element element, { + required RenderBox scrollViewRenderObject, + }) { + assert(element.widget is MessageItem + && (element.widget as MessageItem).item is MessageListMessageItem); + final viewportHeight = scrollViewRenderObject.size.height; + + final messageRenderObject = element.renderObject as RenderBox; + + final messageBottom = messageRenderObject.localToGlobal( + Offset(0, messageRenderObject.size.height), + ancestor: scrollViewRenderObject).dy; + + return 0 < messageBottom && messageBottom <= viewportHeight; + } + + (int, int)? _messagesRecentlyInViewport; + + void _markReadFromScroll() { + final currentRange = _findMessagesInViewport(); + if (currentRange == null) return; + + final (currentFirst, currentLast) = currentRange; + final (prevFirst, prevLast) = _messagesRecentlyInViewport ?? (null, null); + + // ("Hull" as in the "convex hull" around the old and new ranges.) + final firstOfHull = switch ((prevFirst, currentFirst)) { + (int previous, int current) => previous < current ? previous : current, + ( _, int current) => current, + }; + + final lastOfHull = switch ((prevLast, currentLast)) { + (int previous, int current) => previous > current ? previous : current, + ( _, int current) => current, + }; + + final sublist = model.getMessagesRange(firstOfHull, lastOfHull); + if (sublist == null) { + _messagesRecentlyInViewport = null; + return; + } + model.store.markReadFromScroll(sublist.map((message) => message.id)); + + _messagesRecentlyInViewport = currentRange; + } + + bool _effectiveMarkReadOnScroll() { + if (!MessageListPage.debugEnableMarkReadOnScroll) return false; + return widget.markReadOnScroll + ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow); } void _handleScrollMetrics(ScrollMetrics scrollMetrics) { + if (_effectiveMarkReadOnScroll()) { + _markReadFromScroll(); + } + if (scrollMetrics.extentAfter == 0) { _scrollToBottomVisible.value = false; } else { @@ -521,7 +955,10 @@ class _MessageListState extends State with PerAccountStoreAwareStat // but makes things a bit more complicated to reason about. // The cause seems to be that this gets called again with maxScrollExtent // still not yet updated to account for the newly-added messages. - model?.fetchOlder(); + model.fetchOlder(); + } + if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) { + model.fetchNewer(); } } @@ -542,8 +979,20 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { - assert(model != null); - if (!model!.fetched) return const Center(child: CircularProgressIndicator()); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (!model.fetched) return const Center(child: CircularProgressIndicator()); + + if (model.items.isEmpty && model.haveNewest && model.haveOldest) { + final String message; + if (widget.narrow is KeywordSearchNarrow) { + message = zulipLocalizations.emptyMessageListSearch; + } else { + message = zulipLocalizations.emptyMessageList; + } + + return PageBodyEmptyContentPlaceholder(message: message); + } // Pad the left and right insets, for small devices in landscape. return SafeArea( @@ -574,6 +1023,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // MessageList's dartdoc. child: SafeArea( child: ScrollToBottomButton( + model: model, scrollController: scrollController, visible: _scrollToBottomVisible))), ]))))); @@ -581,15 +1031,13 @@ class _MessageListState extends State with PerAccountStoreAwareStat Widget _buildListView(BuildContext context) { const centerSliverKey = ValueKey('center sliver'); - final zulipLocalizations = ZulipLocalizations.of(context); // The list has two slivers: a top sliver growing upward, // and a bottom sliver growing downward. - // Each sliver has some of the items from `model!.items`. - const maxBottomItems = 1; - final totalItems = model!.items.length; - final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems; - final topItems = totalItems - bottomItems; + // Each sliver has some of the items from `model.items`. + final totalItems = model.items.length; + final topItems = model.middleItem; + final bottomItems = totalItems - topItems; // The top sliver has its child 0 as the item just before the // sliver boundary, child 1 as the item before that, and so on. @@ -613,17 +1061,19 @@ class _MessageListState extends State with PerAccountStoreAwareStat // and will not trigger this callback. findChildIndexCallback: (Key key) { final messageId = (key as ValueKey).value; - final itemIndex = model!.findItemWithMessageId(messageId); + final itemIndex = model.findItemWithMessageId(messageId); if (itemIndex == -1) return null; final childIndex = totalItems - 1 - (itemIndex + bottomItems); if (childIndex < 0) return null; return childIndex; }, - childCount: topItems, + childCount: topItems + 1, (context, childIndex) { + if (childIndex == topItems) return _buildStartCap(); + final itemIndex = totalItems - 1 - (childIndex + bottomItems); - final data = model!.items[itemIndex]; - final item = _buildItem(zulipLocalizations, data); + final data = model.items[itemIndex]; + final item = _buildItem(data); return item; })); @@ -651,25 +1101,19 @@ class _MessageListState extends State with PerAccountStoreAwareStat // and will not trigger this callback. findChildIndexCallback: (Key key) { final messageId = (key as ValueKey).value; - final itemIndex = model!.findItemWithMessageId(messageId); + final itemIndex = model.findItemWithMessageId(messageId); if (itemIndex == -1) return null; final childIndex = itemIndex - topItems; if (childIndex < 0) return null; return childIndex; }, - childCount: bottomItems + 3, + childCount: bottomItems + 1, (context, childIndex) { - // To reinforce that the end of the feed has been reached: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - if (childIndex == bottomItems + 2) return const SizedBox(height: 36); - - if (childIndex == bottomItems + 1) return MarkAsReadWidget(narrow: widget.narrow); - - if (childIndex == bottomItems) return TypingStatusWidget(narrow: widget.narrow); + if (childIndex == bottomItems) return _buildEndCap(); final itemIndex = topItems + childIndex; - final data = model!.items[itemIndex]; - return _buildItem(zulipLocalizations, data); + final data = model.items[itemIndex]; + return _buildItem(data); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { @@ -679,6 +1123,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat } return MessageListScrollView( + key: _scrollViewKey, + // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 @@ -701,18 +1147,39 @@ class _MessageListState extends State with PerAccountStoreAwareStat ]); } - Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data) { + Widget _buildStartCap() { + // If we're done fetching older messages, show that. + // Else if we're busy with fetching, then show a loading indicator. + // + // This applies even if the fetch is over, but failed, and we're still + // in backoff from it; and even if the fetch is/was for the other direction. + // The loading indicator really means "busy, working on it"; and that's the + // right summary even if the fetch is internally queued behind other work. + return model.haveOldest ? const _MessageListHistoryStart() + : model.busyFetchingMore ? const _MessageListLoadingMore() + : const SizedBox.shrink(); + } + + Widget _buildEndCap() { + if (model.haveNewest) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + TypingStatusWidget(narrow: widget.narrow), + // TODO perhaps offer mark-as-read even when not done fetching? + MarkAsReadWidget(narrow: widget.narrow), + // To reinforce that the end of the feed has been reached: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 + const SizedBox(height: 36), + ]); + } else if (model.busyFetchingMore) { + // See [_buildStartCap] for why this condition shows a loading indicator. + return const _MessageListLoadingMore(); + } else { + return SizedBox.shrink(); + } + } + + Widget _buildItem(MessageListItem data) { switch (data) { - case MessageListHistoryStartItem(): - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon - case MessageListLoadingItem(): - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: CircularProgressIndicator())); // TODO perhaps a different indicator case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, @@ -726,21 +1193,79 @@ class _MessageListState extends State with PerAccountStoreAwareStat final header = RecipientHeader(message: data.message, narrow: widget.narrow); return MessageItem( key: ValueKey(data.message.id), + narrow: widget.narrow, + header: header, + item: data); + case MessageListOutboxMessageItem(): + final header = RecipientHeader(message: data.message, narrow: widget.narrow); + return MessageItem( + narrow: widget.narrow, header: header, - trailingWhitespace: 11, item: data); } } } +class _MessageListHistoryStart extends StatelessWidget { + const _MessageListHistoryStart(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon + } +} + +class _MessageListLoadingMore extends StatelessWidget { + const _MessageListLoadingMore(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: CircularProgressIndicator())); // TODO perhaps a different indicator + } +} + class ScrollToBottomButton extends StatelessWidget { - const ScrollToBottomButton({super.key, required this.scrollController, required this.visible}); + const ScrollToBottomButton({ + super.key, + required this.model, + required this.scrollController, + required this.visible, + }); - final ValueNotifier visible; + final MessageListView model; final MessageListScrollController scrollController; + final ValueNotifier visible; void _scrollToBottom() { - scrollController.position.scrollToEnd(); + if (model.haveNewest) { + // Scrolling smoothly from here to the bottom won't require any requests + // to the server. + // It also probably isn't *that* far away: the user must have scrolled + // here from there (or from near enough that a fetch reached there), + // so scrolling back there -- at top speed -- shouldn't take too long. + // Go for it. + scrollController.position.scrollToEnd(); + } else { + // This message list doesn't have the messages for the bottom of history. + // There could be quite a lot of history between here and there -- + // for example, at first unread in the combined feed or a busy channel, + // for a user who has some old unreads going back months and years. + // In that case trying to scroll smoothly to the bottom is hopeless. + // + // Given that there were at least 100 messages between this message list's + // initial anchor and the end of history (or else `fetchInitial` would + // have reached the end at the outset), that situation is very likely. + // Even if the end is close by, it's at least one fetch away. + // Instead of scrolling, jump to the end, which is always just one fetch. + model.jumpToEnd(); + } } @override @@ -800,16 +1325,16 @@ class _TypingStatusWidgetState extends State with PerAccount if (narrow is! SendableNarrow) return const SizedBox(); final store = PerAccountStoreWidget.of(context); - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); if (typistIds.isEmpty) return const SizedBox(); final text = switch (typistIds.length) { - 1 => localizations.onePersonTyping( + 1 => zulipLocalizations.onePersonTyping( store.userDisplayName(typistIds.first)), - 2 => localizations.twoPeopleTyping( + 2 => zulipLocalizations.twoPeopleTyping( store.userDisplayName(typistIds.first), store.userDisplayName(typistIds.last)), - _ => localizations.manyPeopleTyping, + _ => zulipLocalizations.manyPeopleTyping, }; return Padding( @@ -846,15 +1371,15 @@ class _MarkAsReadWidgetState extends State { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final unreadCount = store.unreads.countInNarrow(widget.narrow); - final areMessagesRead = unreadCount == 0; + final shouldHide = unreadCount == 0; final messageListTheme = MessageListTheme.of(context); return IgnorePointer( - ignoring: areMessagesRead, + ignoring: shouldHide, child: MarkAsReadAnimation( loading: _loading, - hidden: areMessagesRead, + hidden: shouldHide, child: SizedBox(width: double.infinity, // Design referenced from: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 @@ -965,13 +1490,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: [ @@ -999,30 +1523,42 @@ class DateSeparator extends StatelessWidget { class MessageItem extends StatelessWidget { const MessageItem({ super.key, + required this.narrow, required this.item, required this.header, - this.trailingWhitespace, }); - final MessageListMessageItem item; + final Narrow narrow; + final MessageListMessageBaseItem item; final Widget header; - final double? trailingWhitespace; @override Widget build(BuildContext context) { - final message = item.message; - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); + + final item = this.item; + Widget child = ColoredBox( + color: designVariables.bgMessageRegular, + child: Column(children: [ + switch (item) { + MessageListMessageItem() => MessageWithPossibleSender( + narrow: narrow, + item: item), + MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), + }, + // 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( + isRead: message.flags.contains(MessageFlag.read), + child: child); + } return StickyHeaderItem( allowOverflow: !item.isLastInBlock, header: header, - child: _UnreadMarker( - isRead: message.flags.contains(MessageFlag.read), - child: ColoredBox( - color: messageListTheme.bgMessageRegular, - child: Column(children: [ - MessageWithPossibleSender(item: item), - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), - ])))); + child: child); } } @@ -1075,6 +1611,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return true; case ChannelNarrow(): @@ -1091,24 +1628,15 @@ class StreamMessageRecipientHeader extends StatelessWidget { // https://github.com/zulip/zulip-mobile/issues/5511 final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final streamId = message.conversation.streamId; final topic = message.conversation.topic; - final messageListTheme = MessageListTheme.of(context); - - final subscription = store.subscriptions[streamId]; - final Color backgroundColor; - final Color iconColor; - if (subscription != null) { - final swatch = colorSwatchFor(context, subscription); - backgroundColor = swatch.barBackground; - iconColor = swatch.iconOnBarBackground; - } else { - backgroundColor = messageListTheme.unsubscribedStreamRecipientHeaderBg; - iconColor = designVariables.title; - } + final swatch = colorSwatchFor(context, store.subscriptions[streamId]); + final backgroundColor = swatch.barBackground; + final iconColor = swatch.iconOnBarBackground; final Widget streamWidget; if (!_containsDifferentChannels(narrow)) { @@ -1158,13 +1686,11 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - // ignore: dead_null_aware_expression // null topic names soon to be enabled child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, style: recipientHeaderTextStyle(context, - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, ))), const SizedBox(width: 4), @@ -1252,7 +1778,7 @@ class DmRecipientHeader extends StatelessWidget { child: Icon( color: designVariables.title, size: 16, - ZulipIcons.user)), + ZulipIcons.two_person)), Expanded( child: Text(title, style: recipientHeaderTextStyle(context), @@ -1359,14 +1885,22 @@ String formatHeaderDate( } } -/// A Zulip message, showing the sender's name and avatar if specified. -// Design referenced from: -// - https://github.com/zulip/zulip-mobile/issues/5511 -// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { - const MessageWithPossibleSender({super.key, required this.item}); +// TODO(i18n): web seems to ignore locale in formatting time, but we could do better +final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); - final MessageListMessageItem item; +class _SenderRow extends StatelessWidget { + const _SenderRow({required this.message, required this.showTimestamp}); + + final MessageBase message; + final bool showTimestamp; + + bool _showAsMuted(BuildContext context, PerAccountStore store) { + final message = this.message; + if (!store.isUserMuted(message.senderId)) return false; + if (message is! Message) return false; // i.e., if an outbox message + return !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed(message.id); + } @override Widget build(BuildContext context) { @@ -1374,34 +1908,43 @@ class MessageWithPossibleSender extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; final sender = store.getUser(message.senderId); + final time = _kMessageTimestampFormat + .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + + final showAsMuted = _showAsMuted(context, store); - Widget? senderRow; - if (item.showSender) { - final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); - senderRow = Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ Flexible( child: GestureDetector( - onTap: () => Navigator.push(context, + onTap: () => showAsMuted ? null : Navigator.push(context, ProfilePage.buildRoute(context: context, userId: message.senderId)), child: Row( children: [ - Avatar(size: 32, borderRadius: 3, + Avatar( + size: 32, + borderRadius: 3, + showPresence: false, + replaceIfMuted: showAsMuted, 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, + replaceIfMuted: showAsMuted) + : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), - color: designVariables.title, + color: showAsMuted + ? designVariables.title.withFadedAlpha(0.5) + : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), if (sender?.isBot ?? false) ...[ @@ -1413,24 +1956,47 @@ class MessageWithPossibleSender extends StatelessWidget { ), ], ]))), - const SizedBox(width: 4), - Text(time, - style: TextStyle( - color: messageListTheme.labelTime, - fontSize: 16, - height: (18 / 16), - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ).merge(weightVariableTextStyle(context))), - ]); - } + if (showTimestamp) ...[ + const SizedBox(width: 4), + Text(time, + style: TextStyle( + color: messageListTheme.labelTime, + fontSize: 16, + height: (18 / 16), + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context))), + ], + ])); + } +} + +/// A Zulip message, showing the sender's name and avatar if specified. +// Design referenced from: +// - https://github.com/zulip/zulip-mobile/issues/5511 +// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev +class MessageWithPossibleSender extends StatelessWidget { + const MessageWithPossibleSender({ + super.key, + required this.narrow, + required this.item, + }); + + final Narrow narrow; + final MessageListMessageItem item; - final localizations = ZulipLocalizations.of(context); + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + final message = item.message; + + final zulipLocalizations = ZulipLocalizations.of(context); String? editStateText; switch (message.editState) { case MessageEditState.edited: - editStateText = localizations.messageIsEditedLabel; + editStateText = zulipLocalizations.messageIsEditedLabel; case MessageEditState.moved: - editStateText = localizations.messageIsMovedLabel; + editStateText = zulipLocalizations.messageIsMovedLabel; case MessageEditState.none: } @@ -1445,36 +2011,95 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } + Widget content = MessageContent(message: message, content: item.content); + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + if (editMessageErrorStatus != null) { + // The Figma also fades the sender row: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2076574000 + // We've decided to just fade the message content because that's the only + // thing that's changing. + content = Opacity(opacity: 0.6, child: content); + if (!editMessageErrorStatus) { + // IgnorePointer neutralizes interactable message content like links; + // this seemed appropriate along with the faded appearance. + content = IgnorePointer(child: content); + } else { + content = _RestoreEditMessageGestureDetector(messageId: message.id, + child: content); + } + } + + final tapOpensConversation = switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => true, + }; + + final showAsMuted = store.isUserMuted(message.senderId) + && !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed(message.id); + return GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), + onTap: tapOpensConversation + ? () => unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), + // TODO(#1655) "this view does not mark messages as read on scroll" + initAnchorMessageId: message.id))) + : null, + onLongPress: showAsMuted + ? null // TODO write a test for this + : () => showMessageActionSheet(context: context, message: message), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), + if (item.showSender) + _SenderRow(message: message, showTimestamp: true), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MessageContent(message: message, content: item.content), - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - 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))), - ])), + Expanded(child: showAsMuted + ? Align( + alignment: AlignmentDirectional.topStart, + child: ZulipWebUiKitButton( + label: zulipLocalizations.revealButtonLabel, + icon: ZulipIcons.eye, + size: ZulipWebUiKitButtonSize.small, + intent: ZulipWebUiKitButtonIntent.neutral, + attention: ZulipWebUiKitButtonAttention.minimal, + onPressed: () { + MessageListPage.ancestorOf(context).revealMutedMessage(message.id); + })) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) + 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), ]), @@ -1482,5 +2107,201 @@ class MessageWithPossibleSender extends StatelessWidget { } } -// TODO(i18n): web seems to ignore locale in formatting time, but we could do better -final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); +class _EditMessageStatusRow extends StatelessWidget { + const _EditMessageStatusRow({ + required this.messageId, + required this.status, + }); + + final int messageId; + final bool status; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final baseTextStyle = TextStyle( + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)); + + return switch (status) { + // TODO parse markdown and show new content as local echo? + 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.btnLabelAttLowIntDanger), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditFailedLabel))), + }; + } +} + +class _RestoreEditMessageGestureDetector extends StatelessWidget { + const _RestoreEditMessageGestureDetector({ + required this.messageId, + required this.child, + }); + + final int messageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + 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); + }, + child: child); + } +} + +/// A "local echo" placeholder for a Zulip message to be sent by the self-user. +/// +/// See also [OutboxMessage]. +class OutboxMessageWithPossibleSender extends StatelessWidget { + const OutboxMessageWithPossibleSender({super.key, required this.item}); + + final MessageListOutboxMessageItem item; + + @override + Widget build(BuildContext context) { + final message = item.message; + final localMessageId = message.localMessageId; + + // This is adapted from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + Widget content = DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes)); + + switch (message.state) { + case OutboxMessageState.hidden: + throw StateError('Hidden OutboxMessage messages should not appear in message lists'); + case OutboxMessageState.waiting: + break; + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + // TODO(#576): When we support rendered-content local echo, + // use IgnorePointer along with this faded appearance, + // like we do for the failed-message-edit state + content = _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Opacity(opacity: 0.6, child: content)); + } + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Column(children: [ + if (item.showSender) + _SenderRow(message: message, showTimestamp: false), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + _OutboxMessageStatusRow( + localMessageId: localMessageId, outboxMessageState: message.state), + ])), + ])); + } +} + +class _OutboxMessageStatusRow extends StatelessWidget { + const _OutboxMessageStatusRow({ + required this.localMessageId, + required this.outboxMessageState, + }); + + final int localMessageId; + final OutboxMessageState outboxMessageState; + + @override + Widget build(BuildContext context) { + switch (outboxMessageState) { + case OutboxMessageState.hidden: + assert(false, + 'Hidden OutboxMessage messages should not appear in message lists'); + return SizedBox.shrink(); + + case OutboxMessageState.waiting: + final designVariables = DesignVariables.of(context); + return Padding( + padding: const EdgeInsetsGeometry.only(bottom: 2), + child: LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withFadedAlpha(0.5), + backgroundColor: designVariables.foreground.withFadedAlpha(0.2))); + + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Text( + zulipLocalizations.messageNotSentLabel, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.btnLabelAttLowIntDanger, + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))))); + } + } +} + +class _RestoreOutboxMessageGestureDetector extends StatelessWidget { + const _RestoreOutboxMessageGestureDetector({ + required this.localMessageId, + required this.child, + }); + + final int localMessageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-outbox-message from any message-list page + if (composeBoxState == null) return; + composeBoxState.restoreMessageNotSent(localMessageId); + }, + child: child); + } +} diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart new file mode 100644 index 0000000000..f81a66de42 --- /dev/null +++ b/lib/widgets/new_dm_sheet.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/autocomplete.dart'; +import '../model/narrow.dart'; +import '../model/store.dart'; +import 'color.dart'; +import 'content.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +void showNewDmSheet(BuildContext context) { + final pageContext = PageRoot.contextOf(context); + final store = PerAccountStoreWidget.of(context); + showModalBottomSheet( + context: pageContext, + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext context) => Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: PerAccountStoreWidget( + accountId: store.accountId, + child: NewDmPicker()))); +} + +@visibleForTesting +class NewDmPicker extends StatefulWidget { + const NewDmPicker({super.key}); + + @override + State createState() => _NewDmPickerState(); +} + +class _NewDmPickerState extends State with PerAccountStoreAwareStateMixin { + late TextEditingController searchController; + late ScrollController resultsScrollController; + Set selectedUserIds = {}; + List filteredUsers = []; + List sortedUsers = []; + + @override + void initState() { + super.initState(); + searchController = TextEditingController()..addListener(_handleSearchUpdate); + resultsScrollController = ScrollController(); + } + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + _initSortedUsers(store); + } + + @override + void dispose() { + searchController.dispose(); + resultsScrollController.dispose(); + super.dispose(); + } + + void _initSortedUsers(PerAccountStore store) { + sortedUsers = List.from(store.allUsers) + ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); + _updateFilteredUsers(store); + } + + void _handleSearchUpdate() { + final store = PerAccountStoreWidget.of(context); + _updateFilteredUsers(store); + } + + // Function to sort users based on recency of DM's + // TODO: switch to using an `AutocompleteView` for users + void _updateFilteredUsers(PerAccountStore store) { + final excludeSelfUser = selectedUserIds.isNotEmpty + && !selectedUserIds.contains(store.selfUserId); + final searchTextLower = searchController.text.toLowerCase(); + + final result = []; + for (final user in sortedUsers) { + if (excludeSelfUser && user.userId == store.selfUserId) continue; + if (user.fullName.toLowerCase().contains(searchTextLower)) { + result.add(user); + } + } + + setState(() { + filteredUsers = result; + }); + + if (resultsScrollController.hasClients) { + // Jump to the first results for the new query. + resultsScrollController.jumpTo(0); + } + } + + void _selectUser(int userId) { + assert(!selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.add(userId); + if (userId != store.selfUserId) { + selectedUserIds.remove(store.selfUserId); + } + _updateFilteredUsers(store); + } + + void _unselectUser(int userId) { + assert(selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.remove(userId); + _updateFilteredUsers(store); + } + + void _handleUserTap(int userId) { + selectedUserIds.contains(userId) + ? _unselectUser(userId) + : _selectUser(userId); + searchController.clear(); + } + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _NewDmHeader(selectedUserIds: selectedUserIds), + _NewDmSearchBar( + controller: searchController, + selectedUserIds: selectedUserIds, + unselectUser: _unselectUser), + Expanded( + child: _NewDmUserList( + filteredUsers: filteredUsers, + selectedUserIds: selectedUserIds, + scrollController: resultsScrollController, + onUserTapped: (userId) => _handleUserTap(userId))), + ]); + } +} + +class _NewDmHeader extends StatelessWidget { + const _NewDmHeader({required this.selectedUserIds}); + + final Set selectedUserIds; + + Widget _buildCancelButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return GestureDetector( + onTap: Navigator.of(context).pop, + child: Text(zulipLocalizations.dialogCancel, style: TextStyle( + color: designVariables.icon, + fontSize: 20, + height: 30 / 20))); + } + + Widget _buildComposeButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final color = selectedUserIds.isEmpty + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; + + return GestureDetector( + onTap: selectedUserIds.isEmpty ? null : () { + final store = PerAccountStoreWidget.of(context); + final narrow = DmNarrow.withUsers( + selectedUserIds.toList(), + selfUserId: store.selfUserId); + Navigator.pushReplacement(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, + style: TextStyle( + color: color, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 10, 8, 6), + child: Row(children: [ + _buildCancelButton(context), + SizedBox(width: 8), + Expanded(child: Text(zulipLocalizations.newDmSheetScreenTitle, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center)), + SizedBox(width: 8), + _buildComposeButton(context), + ])); + } +} + +class _NewDmSearchBar extends StatelessWidget { + const _NewDmSearchBar({ + required this.controller, + required this.selectedUserIds, + required this.unselectUser, + }); + + final TextEditingController controller; + final Set selectedUserIds; + final void Function(int) unselectUser; + + // void _removeUser + + Widget _buildSearchField(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final hintText = selectedUserIds.isEmpty + ? zulipLocalizations.newDmSheetSearchHintEmpty + : zulipLocalizations.newDmSheetSearchHintSomeSelected; + + return TextField( + controller: controller, + autofocus: true, + cursorColor: designVariables.foreground, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 17, + height: 22 / 17), + scrollPadding: EdgeInsets.zero, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: hintText, + hintStyle: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 22 / 17))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Container( + constraints: const BoxConstraints(maxHeight: 124), + decoration: BoxDecoration(color: designVariables.bgSearchInput), + child: SingleChildScrollView( + reverse: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final userId in selectedUserIds) + _SelectedUserChip(userId: userId, unselectUser: unselectUser), + // The IntrinsicWidth lets the text field participate in the Wrap + // when its content fits on the same line with a user chip, + // by preventing it from expanding to fill the available width. See: + // https://github.com/zulip/zulip-flutter/pull/1322#discussion_r2094112488 + IntrinsicWidth(child: _buildSearchField(context)), + ])))); + } +} + +class _SelectedUserChip extends StatelessWidget { + const _SelectedUserChip({ + required this.userId, + required this.unselectUser, + }); + + final int userId; + final void Function(int) unselectUser; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final clampedTextScaler = MediaQuery.textScalerOf(context) + .clamp(maxScaleFactor: 1.5); + + return GestureDetector( + onTap: () => unselectUser(userId), + child: DecoratedBox( + decoration: BoxDecoration( + color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(3)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Avatar(userId: userId, size: clampedTextScaler.scale(22), borderRadius: 3), + Flexible( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), + child: Text(store.userDisplayName(userId), + textScaler: clampedTextScaler, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: designVariables.labelMenuButton)))), + ]))); + } +} + +class _NewDmUserList extends StatelessWidget { + const _NewDmUserList({ + required this.filteredUsers, + required this.selectedUserIds, + required this.scrollController, + required this.onUserTapped, + }); + + final List filteredUsers; + final Set selectedUserIds; + final ScrollController scrollController; + final void Function(int userId) onUserTapped; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (filteredUsers.isEmpty) { + // TODO(design): Missing in Figma. + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + textAlign: TextAlign.center, + zulipLocalizations.newDmSheetNoUsersFound, + style: TextStyle( + color: designVariables.labelMenuButton, + fontSize: 16)))); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomScrollView(controller: scrollController, slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 8), + sliver: SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: filteredUsers.length, + itemBuilder: (context, index) { + final user = filteredUsers[index]; + final isSelected = selectedUserIds.contains(user.userId); + + return _NewDmUserListItem( + userId: user.userId, + isSelected: isSelected, + onTapped: onUserTapped, + ); + }))), + ])); + } +} + +class _NewDmUserListItem extends StatelessWidget { + const _NewDmUserListItem({ + required this.userId, + required this.isSelected, + required this.onTapped, + }); + + final int userId; + final bool isSelected; + final void Function(int userId) onTapped; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + return Material( + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadius.circular(10), + color: isSelected + ? designVariables.bgMenuButtonSelected + : Colors.transparent, + child: InkWell( + highlightColor: designVariables.bgMenuButtonSelected, + splashFactory: NoSplash.splashFactory, + onTap: () => onTapped(userId), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(0, 6, 12, 6), + child: Row(children: [ + SizedBox(width: 8), + isSelected + ? Icon(size: 24, + color: designVariables.radioFillSelected, + ZulipIcons.check_circle_checked) + : Icon(size: 24, + color: designVariables.radioBorder, + ZulipIcons.check_circle_unchecked), + SizedBox(width: 10), + Avatar(userId: userId, size: 32, borderRadius: 3), + SizedBox(width: 8), + Expanded( + child: Text(store.userDisplayName(userId), + style: TextStyle( + fontSize: 17, + height: 19 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)))), + ])))); + } +} diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index a2c6fe52a1..35bdf34923 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; /// An [InheritedWidget] for near the root of a page's widget subtree, /// providing its [BuildContext]. @@ -210,3 +212,40 @@ class LoadingPlaceholderPage extends StatelessWidget { ); } } + +/// A "no content here" message for when a page has no content to show. +/// +/// Suitable for the inbox, the message-list page, etc. +/// +/// This handles the horizontal device insets +/// and the bottom inset when needed (in a message list with no compose box). +/// The top inset is handled externally by the app bar. +// TODO(#311) If the message list gets a bottom nav, the bottom inset will +// always be handled externally too; simplify implementation and dartdoc. +class PageBodyEmptyContentPlaceholder extends StatelessWidget { + const PageBodyEmptyContentPlaceholder({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return SafeArea( + minimum: EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Padding( + padding: EdgeInsets.only(top: 48), + child: Align( + alignment: Alignment.topCenter, + // TODO leading and trailing elements, like in Figma (given as SVGs): + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev + child: Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message)))); + } +} diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index f1328b3367..27c8486fe8 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -44,15 +44,34 @@ class ProfilePage extends StatelessWidget { return const _ProfileErrorPage(); } - final displayEmail = store.userDisplayEmail(user); + final nameStyle = _TextStyles.primaryFieldText + .merge(weightVariableTextStyle(context, wght: 700)); + + final displayEmail = store.userDisplayEmail(userId); final items = [ Center( - child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), + child: Avatar( + userId: userId, + size: 200, + borderRadius: 200 / 8, + // Would look odd with this large image; + // we'll show it by the user's name instead. + showPresence: false, + replaceIfMuted: false, + )), const SizedBox(height: 16), - Text(user.fullName, + Text.rich( + TextSpan(children: [ + PresenceCircle.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + ), + // TODO write a test where the user is muted; check this and avatar + TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + ]), textAlign: TextAlign.center, - style: _TextStyles.primaryFieldText - .merge(weightVariableTextStyle(context, wght: 700))), + style: nameStyle), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -75,7 +94,9 @@ class ProfilePage extends StatelessWidget { ]; return Scaffold( - appBar: ZulipAppBar(title: Text(user.fullName)), + appBar: ZulipAppBar( + // TODO write a test where the user is muted + title: Text(store.userDisplayName(userId, replaceIfMuted: false))), body: SingleChildScrollView( child: Center( child: ConstrainedBox( diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..97c53ac4b1 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'new_dm_sheet.dart'; +import 'page.dart'; import 'store.dart'; +import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; @@ -48,19 +52,33 @@ class _RecentDmConversationsPageBodyState extends State createState() => _NewDmButtonState(); +} + +class _NewDmButtonState extends State<_NewDmButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final fabBgColor = _pressed + ? designVariables.fabBgPressed + : designVariables.fabBg; + final fabLabelColor = _pressed + ? designVariables.fabLabelPressed + : designVariables.fabLabel; + + return GestureDetector( + onTap: () => showNewDmSheet(context), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 20, 12), + decoration: BoxDecoration( + color: fabBgColor, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow( + color: designVariables.fabShadow, + blurRadius: _pressed ? 12 : 16, + offset: _pressed + ? const Offset(0, 2) + : const Offset(0, 4)), + ]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(ZulipIcons.plus, size: 24, color: fabLabelColor), + const SizedBox(width: 8), + Text( + zulipLocalizations.newDmFabButtonLabel, + style: TextStyle( + fontSize: 20, + height: 24 / 20, + color: fabLabelColor, + ).merge(weightVariableTextStyle(context, wght: 500))), + ]))); + } +} diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 9e7581c539..394415a8be 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -23,6 +23,8 @@ class SettingsPage extends StatelessWidget { body: Column(children: [ const _ThemeSetting(), const _BrowserPreferenceSetting(), + const _VisitFirstUnreadSetting(), + const _MarkReadOnScrollSetting(), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), @@ -53,7 +55,10 @@ class _ThemeSetting extends StatelessWidget { themeSetting: themeSettingOption, zulipLocalizations: zulipLocalizations)), value: themeSettingOption, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use groupValue: globalSettings.themeSetting, + // ignore: deprecated_member_use onChanged: (newValue) => _handleChange(context, newValue)), ]); } @@ -82,6 +87,150 @@ class _BrowserPreferenceSetting extends StatelessWidget { } } +class _VisitFirstUnreadSetting extends StatelessWidget { + const _VisitFirstUnreadSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.initialAnchorSettingTitle), + subtitle: Text(VisitFirstUnreadSettingPage._valueDisplayName( + globalSettings.visitFirstUnread, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + VisitFirstUnreadSettingPage.buildRoute())); + } +} + +class VisitFirstUnreadSettingPage extends StatelessWidget { + const VisitFirstUnreadSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const VisitFirstUnreadSettingPage()); + } + + static String _valueDisplayName(VisitFirstUnreadSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + VisitFirstUnreadSetting.always => + zulipLocalizations.initialAnchorSettingFirstUnreadAlways, + VisitFirstUnreadSetting.conversations => + zulipLocalizations.initialAnchorSettingFirstUnreadConversations, + VisitFirstUnreadSetting.never => + zulipLocalizations.initialAnchorSettingNewestAlways, + }; + } + + void _handleChange(BuildContext context, VisitFirstUnreadSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setVisitFirstUnread(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)), + body: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use + groupValue: globalSettings.visitFirstUnread, + // ignore: deprecated_member_use + onChanged: (newValue) => _handleChange(context, newValue)), + ])); + } +} + +class _MarkReadOnScrollSetting extends StatelessWidget { + const _MarkReadOnScrollSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.markReadOnScrollSettingTitle), + subtitle: Text(MarkReadOnScrollSettingPage._valueDisplayName( + globalSettings.markReadOnScroll, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + MarkReadOnScrollSettingPage.buildRoute())); + } +} + +class MarkReadOnScrollSettingPage extends StatelessWidget { + const MarkReadOnScrollSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const MarkReadOnScrollSettingPage()); + } + + static String _valueDisplayName(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => + zulipLocalizations.markReadOnScrollSettingAlways, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversations, + MarkReadOnScrollSetting.never => + zulipLocalizations.markReadOnScrollSettingNever, + }; + } + + static String? _valueDescription(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversationsDescription, + MarkReadOnScrollSetting.never => null, + }; + } + + void _handleChange(BuildContext context, MarkReadOnScrollSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setMarkReadOnScroll(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)), + body: Column(children: [ + ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)), + for (final value in MarkReadOnScrollSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + subtitle: () { + final result = _valueDescription(value, + zulipLocalizations: zulipLocalizations); + return result == null ? null : Text(result); + }(), + value: value, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use + groupValue: globalSettings.markReadOnScroll, + // ignore: deprecated_member_use + onChanged: (newValue) => _handleChange(context, newValue)), + ])); + } +} + class ExperimentalFeaturesPage extends StatelessWidget { const ExperimentalFeaturesPage({super.key}); diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index ab287b745a..f5fe4d3cc6 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -18,10 +18,18 @@ import 'page.dart'; class GlobalStoreWidget extends StatefulWidget { const GlobalStoreWidget({ super.key, + this.blockingFuture, this.placeholder = const LoadingPlaceholder(), required this.child, }); + /// An additional future to await before showing the child. + /// + /// If [blockingFuture] is non-null, then this widget will build [child] + /// only after the future completes. This widget's behavior is not affected + /// by whether the future's completion is with a value or with an error. + final Future? blockingFuture; + final Widget placeholder; final Widget child; @@ -87,6 +95,9 @@ class _GlobalStoreWidgetState extends State { super.initState(); (() async { final store = await ZulipBinding.instance.getGlobalStoreUniquely(); + if (widget.blockingFuture != null) { + await widget.blockingFuture!.catchError((_) {}); + } setState(() { this.store = store; }); diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 062bb9743e..ff3db94391 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -7,6 +7,7 @@ import '../model/unreads.dart'; import 'action_sheet.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -94,13 +95,17 @@ class _SubscriptionListPageBodyState extends State wit _sortSubs(pinned); _sortSubs(unpinned); + if (pinned.isEmpty && unpinned.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#188) add e.g. "Go to 'All channels' and join some of them." + message: zulipLocalizations.channelsEmptyPlaceholder); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. bottom: false, child: CustomScrollView( slivers: [ - if (pinned.isEmpty && unpinned.isEmpty) - const _NoSubscriptionsItem(), if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), @@ -118,27 +123,6 @@ class _SubscriptionListPageBodyState extends State wit } } -class _NoSubscriptionsItem extends StatelessWidget { - const _NoSubscriptionsItem(); - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(10), - child: Text(zulipLocalizations.subscriptionListNoChannels, - textAlign: TextAlign.center, - style: TextStyle( - color: designVariables.subscriptionListHeaderText, - fontSize: 18, - height: (20 / 18), - )))); - } -} - class _SubscriptionListHeader extends StatelessWidget { const _SubscriptionListHeader({required this.label}); diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index eea9677045..8bed69579d 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -130,11 +130,14 @@ class DesignVariables extends ThemeExtension { static final light = DesignVariables._( background: const Color(0xffffffff), bannerBgIntDanger: const Color(0xfff2e4e4), + bannerBgIntInfo: const Color(0xffddecf6), + bannerTextIntInfo: const Color(0xff06037c), bgBotBar: const Color(0xfff6f6f6), bgContextMenu: const Color(0xfff2f2f2), 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), @@ -144,6 +147,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: const Color(0xff3c6bff).withValues(alpha: 0.12), btnLabelAttHigh: const Color(0xffffffff), btnLabelAttLowIntDanger: const Color(0xffc0070a), + btnLabelAttLowIntInfo: const Color(0xff2347c6), btnLabelAttMediumIntDanger: const Color(0xffac0508), btnLabelAttMediumIntInfo: const Color(0xff1027a6), btnShadowAttMed: const Color(0xff000000).withValues(alpha: 0.20), @@ -154,23 +158,41 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), + fabBg: const Color(0xff6e69f3), + fabBgPressed: const Color(0xff6159e1), + fabLabel: const Color(0xfff1f3fe), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff2b0e8a).withValues(alpha: 0.4), foreground: const Color(0xff000000), icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), + labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), + neutralButtonBg: const Color(0xff8c84ae), + neutralButtonLabel: const Color(0xff433d5c), + radioBorder: Color(0xffbbbdc8), + radioFillSelected: Color(0xff4370f0), + statusAway: Color(0xff73788c).withValues(alpha: 0.25), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #d5bb6c. + statusIdle: Color(0xfff5b266), + + statusOnline: Color(0xff46aa62), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), textMessage: const Color(0xff262626), + textMessageMuted: const Color(0xff262626).withValues(alpha: 0.6), channelColorSwatches: ChannelColorSwatches.light, + avatarPlaceholderBg: const Color(0x33808080), + avatarPlaceholderIcon: Colors.black.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), - groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), @@ -187,11 +209,14 @@ class DesignVariables extends ThemeExtension { static final dark = DesignVariables._( background: const Color(0xff000000), bannerBgIntDanger: const Color(0xff461616), + bannerBgIntInfo: const Color(0xff00253d), + bannerTextIntInfo: const Color(0xffcbdbfd), bgBotBar: const Color(0xff222222), bgContextMenu: const Color(0xff262626), 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), @@ -201,6 +226,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: const Color(0xff97b6fe).withValues(alpha: 0.12), btnLabelAttHigh: const Color(0xffffffff).withValues(alpha: 0.85), btnLabelAttLowIntDanger: const Color(0xffff8b7c), + btnLabelAttLowIntInfo: const Color(0xff84a8fd), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), btnLabelAttMediumIntInfo: const Color(0xff97b6fe), btnShadowAttMed: const Color(0xffffffff).withValues(alpha: 0.21), @@ -211,26 +237,44 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), + fabBg: const Color(0xff4f42c9), + fabBgPressed: const Color(0xff4331b8), + fabLabel: const Color(0xffeceefc), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff18171c), foreground: const Color(0xffffffff), icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), + labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), + neutralButtonBg: const Color(0xffd4d1e0), + neutralButtonLabel: const Color(0xffa9a3c2), + radioBorder: Color(0xff626573), + radioFillSelected: Color(0xff4e7cfa), + statusAway: Color(0xffabaeba).withValues(alpha: 0.30), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #8c853b. + statusIdle: Color(0xffae640a), + + statusOnline: Color(0xff44bb66), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), textMessage: const Color(0xffffffff).withValues(alpha: 0.8), + textMessageMuted: const Color(0xffffffff).withValues(alpha: 0.5), channelColorSwatches: ChannelColorSwatches.dark, + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderBg: const Color(0x33cccccc), + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderIcon: Colors.white.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), @@ -252,11 +296,14 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, required this.bannerBgIntDanger, + required this.bannerBgIntInfo, + required this.bannerTextIntInfo, required this.bgBotBar, required this.bgContextMenu, required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, @@ -266,6 +313,7 @@ class DesignVariables extends ThemeExtension { required this.btnBgAttMediumIntInfoNormal, required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, + required this.btnLabelAttLowIntInfo, required this.btnLabelAttMediumIntDanger, required this.btnLabelAttMediumIntInfo, required this.btnShadowAttMed, @@ -277,22 +325,36 @@ class DesignVariables extends ThemeExtension { required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, + required this.fabBg, + required this.fabBgPressed, + required this.fabLabel, + required this.fabLabelPressed, + required this.fabShadow, required this.icon, required this.iconSelected, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, + required this.labelSearchPrompt, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, + required this.radioBorder, + required this.radioFillSelected, + required this.statusAway, + required this.statusIdle, + required this.statusOnline, required this.textInput, required this.title, required this.bgSearchInput, required this.textMessage, + required this.textMessageMuted, required this.channelColorSwatches, + required this.avatarPlaceholderBg, + required this.avatarPlaceholderIcon, required this.contextMenuCancelBg, required this.contextMenuCancelPressedBg, required this.dmHeaderBg, - required this.groupDmConversationIcon, - required this.groupDmConversationIconBg, required this.inboxItemIconMarker, required this.loginOrDivider, required this.loginOrDividerText, @@ -318,11 +380,14 @@ class DesignVariables extends ThemeExtension { final Color background; final Color bannerBgIntDanger; + final Color bannerBgIntInfo; + final Color bannerTextIntInfo; final Color bgBotBar; final Color bgContextMenu; final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; @@ -332,6 +397,7 @@ class DesignVariables extends ThemeExtension { final Color btnBgAttMediumIntInfoNormal; final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; + final Color btnLabelAttLowIntInfo; final Color btnLabelAttMediumIntDanger; final Color btnLabelAttMediumIntInfo; final Color btnShadowAttMed; @@ -342,27 +408,41 @@ class DesignVariables extends ThemeExtension { final Color contextMenuItemMeta; final Color contextMenuItemText; final Color editorButtonPressedBg; + final Color fabBg; + final Color fabBgPressed; + final Color fabLabel; + final Color fabLabelPressed; + final Color fabShadow; final Color foreground; final Color icon; final Color iconSelected; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; + final Color labelSearchPrompt; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; + final Color radioBorder; + final Color radioFillSelected; + final Color statusAway; + final Color statusIdle; + final Color statusOnline; final Color textInput; final Color title; final Color bgSearchInput; final Color textMessage; + final Color textMessageMuted; // Not exactly from the Figma design, but from Vlad anyway. final ChannelColorSwatches channelColorSwatches; // Not named variables in Figma; taken from older Figma drafts, or elsewhere. + final Color avatarPlaceholderBg; + final Color avatarPlaceholderIcon; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color contextMenuCancelPressedBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color groupDmConversationIcon; - final Color groupDmConversationIconBg; final Color inboxItemIconMarker; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -379,11 +459,14 @@ class DesignVariables extends ThemeExtension { DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bannerBgIntInfo, + Color? bannerTextIntInfo, Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, @@ -393,6 +476,7 @@ class DesignVariables extends ThemeExtension { Color? btnBgAttMediumIntInfoNormal, Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, + Color? btnLabelAttLowIntInfo, Color? btnLabelAttMediumIntDanger, Color? btnLabelAttMediumIntInfo, Color? btnShadowAttMed, @@ -403,23 +487,37 @@ class DesignVariables extends ThemeExtension { Color? contextMenuItemMeta, Color? contextMenuItemText, Color? editorButtonPressedBg, + Color? fabBg, + Color? fabBgPressed, + Color? fabLabel, + Color? fabLabelPressed, + Color? fabShadow, Color? foreground, Color? icon, Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, + Color? labelSearchPrompt, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, + Color? radioBorder, + Color? radioFillSelected, + Color? statusAway, + Color? statusIdle, + Color? statusOnline, Color? textInput, Color? title, Color? bgSearchInput, Color? textMessage, + Color? textMessageMuted, ChannelColorSwatches? channelColorSwatches, + Color? avatarPlaceholderBg, + Color? avatarPlaceholderIcon, Color? contextMenuCancelBg, Color? contextMenuCancelPressedBg, Color? dmHeaderBg, - Color? groupDmConversationIcon, - Color? groupDmConversationIconBg, Color? inboxItemIconMarker, Color? loginOrDivider, Color? loginOrDividerText, @@ -435,11 +533,14 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: background ?? this.background, bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, + bannerBgIntInfo: bannerBgIntInfo ?? this.bannerBgIntInfo, + bannerTextIntInfo: bannerTextIntInfo ?? this.bannerTextIntInfo, bgBotBar: bgBotBar ?? this.bgBotBar, bgContextMenu: bgContextMenu ?? this.bgContextMenu, 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, @@ -449,6 +550,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttLowIntInfo: btnLabelAttLowIntInfo ?? this.btnLabelAttLowIntInfo, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, btnShadowAttMed: btnShadowAttMed ?? this.btnShadowAttMed, @@ -460,22 +562,36 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, + fabBg: fabBg ?? this.fabBg, + fabBgPressed: fabBgPressed ?? this.fabBgPressed, + fabLabel: fabLabel ?? this.fabLabel, + fabLabelPressed: fabLabelPressed ?? this.fabLabelPressed, + fabShadow: fabShadow ?? this.fabShadow, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, + labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, + neutralButtonBg: neutralButtonBg ?? this.neutralButtonBg, + neutralButtonLabel: neutralButtonLabel ?? this.neutralButtonLabel, + radioBorder: radioBorder ?? this.radioBorder, + radioFillSelected: radioFillSelected ?? this.radioFillSelected, + statusAway: statusAway ?? this.statusAway, + statusIdle: statusIdle ?? this.statusIdle, + statusOnline: statusOnline ?? this.statusOnline, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, textMessage: textMessage ?? this.textMessage, + textMessageMuted: textMessageMuted ?? this.textMessageMuted, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, + avatarPlaceholderBg: avatarPlaceholderBg ?? this.avatarPlaceholderBg, + avatarPlaceholderIcon: avatarPlaceholderIcon ?? this.avatarPlaceholderIcon, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, contextMenuCancelPressedBg: contextMenuCancelPressedBg ?? this.contextMenuCancelPressedBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, - groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, @@ -498,11 +614,14 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: Color.lerp(background, other.background, t)!, bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, + bannerBgIntInfo: Color.lerp(bannerBgIntInfo, other.bannerBgIntInfo, t)!, + bannerTextIntInfo: Color.lerp(bannerTextIntInfo, other.bannerTextIntInfo, t)!, bgBotBar: Color.lerp(bgBotBar, other.bgBotBar, t)!, bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, 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)!, @@ -512,6 +631,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: Color.lerp(btnBgAttMediumIntInfoNormal, other.btnBgAttMediumIntInfoNormal, t)!, btnLabelAttHigh: Color.lerp(btnLabelAttHigh, other.btnLabelAttHigh, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, + btnLabelAttLowIntInfo: Color.lerp(btnLabelAttLowIntInfo, other.btnLabelAttLowIntInfo, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, btnLabelAttMediumIntInfo: Color.lerp(btnLabelAttMediumIntInfo, other.btnLabelAttMediumIntInfo, t)!, btnShadowAttMed: Color.lerp(btnShadowAttMed, other.btnShadowAttMed, t)!, @@ -523,22 +643,36 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + fabBg: Color.lerp(fabBg, other.fabBg, t)!, + fabBgPressed: Color.lerp(fabBgPressed, other.fabBgPressed, t)!, + fabLabel: Color.lerp(fabLabel, other.fabLabel, t)!, + fabLabelPressed: Color.lerp(fabLabelPressed, other.fabLabelPressed, t)!, + fabShadow: Color.lerp(fabShadow, other.fabShadow, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, + labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + neutralButtonBg: Color.lerp(neutralButtonBg, other.neutralButtonBg, t)!, + neutralButtonLabel: Color.lerp(neutralButtonLabel, other.neutralButtonLabel, t)!, + radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, + radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, + statusAway: Color.lerp(statusAway, other.statusAway, t)!, + statusIdle: Color.lerp(statusIdle, other.statusIdle, t)!, + statusOnline: Color.lerp(statusOnline, other.statusOnline, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, textMessage: Color.lerp(textMessage, other.textMessage, t)!, + textMessageMuted: Color.lerp(textMessageMuted, other.textMessageMuted, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), + avatarPlaceholderBg: Color.lerp(avatarPlaceholderBg, other.avatarPlaceholderBg, t)!, + avatarPlaceholderIcon: Color.lerp(avatarPlaceholderIcon, other.avatarPlaceholderIcon, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, contextMenuCancelPressedBg: Color.lerp(contextMenuCancelPressedBg, other.contextMenuCancelPressedBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, - groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, @@ -554,10 +688,19 @@ class DesignVariables extends ThemeExtension { } } +// This is taken from: +// https://github.com/zulip/zulip/blob/b248e2d93/web/src/stream_data.ts#L40 +const kDefaultChannelColorSwatchBaseColor = 0xffc2c2c2; + /// The theme-appropriate [ChannelColorSwatch] based on [subscription.color]. /// +/// If [subscription] is null, [ChannelColorSwatch] will be based on +/// [kDefaultChannelColorSwatchBaseColor]. +/// /// For how this value is cached, see [ChannelColorSwatches.forBaseColor]. -ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription subscription) { +// TODO(#188) pick different colors for unsubscribed channels +ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription? subscription) { return DesignVariables.of(context) - .channelColorSwatches.forBaseColor(subscription.color); + .channelColorSwatches.forBaseColor( + subscription?.color ?? kDefaultChannelColorSwatchBaseColor); } 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/macos/Podfile.lock b/macos/Podfile.lock index dab9b53101..eb96189d39 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -7,65 +7,65 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - Firebase/Messaging (11.10.0): + - Firebase/CoreOnly (11.13.0): + - FirebaseCore (~> 11.13.0) + - Firebase/Messaging (11.13.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (~> 11.10.0) + - FirebaseMessaging (~> 11.13.0) + - firebase_core (3.14.0): + - Firebase/CoreOnly (~> 11.13.0) - FlutterMacOS - - firebase_messaging (15.2.5): - - Firebase/CoreOnly (~> 11.10.0) - - Firebase/Messaging (~> 11.10.0) + - firebase_messaging (15.2.7): + - Firebase/CoreOnly (~> 11.13.0) + - Firebase/Messaging (~> 11.13.0) - firebase_core - FlutterMacOS - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.13.0): + - FirebaseCoreInternal (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.13.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.13.0): + - FirebaseCore (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.13.0): + - FirebaseCore (~> 11.13.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - FlutterMacOS (1.0.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - nanopb (3.30910.0): @@ -81,23 +81,23 @@ PODS: - PromisesObjC (2.4.0) - share_plus (0.0.1): - FlutterMacOS - - sqlite3 (3.49.1): - - sqlite3/common (= 3.49.1) - - sqlite3/common (3.49.1) - - sqlite3/dbstatvtab (3.49.1): + - sqlite3 (3.50.1): + - sqlite3/common (= 3.50.1) + - sqlite3/common (3.50.1) + - sqlite3/dbstatvtab (3.50.1): - sqlite3/common - - sqlite3/fts5 (3.49.1): + - sqlite3/fts5 (3.50.1): - sqlite3/common - - sqlite3/math (3.49.1): + - sqlite3/math (3.50.1): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/perf-threadsafe (3.50.1): - sqlite3/common - - sqlite3/rtree (3.49.1): + - sqlite3/rtree (3.50.1): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.1) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math @@ -175,23 +175,23 @@ SPEC CHECKSUMS: device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_core: efd50ad8177dc489af1b9163a560359cf1b30597 - firebase_messaging: acf2566068a55d7eb8cddfee5b094754070a5b88 - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 + firebase_core: 1095fcf33161d99bc34aa10f7c0d89414a208d15 + firebase_messaging: 6417056ffb85141607618ddfef9fec9f3caab3ea + FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 + FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c + FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 + FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 + sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 diff --git a/pigeon/android_notifications.dart b/pigeon/android_notifications.dart new file mode 100644 index 0000000000..901369001c --- /dev/null +++ b/pigeon/android_notifications.dart @@ -0,0 +1,306 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_notifications.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', + kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), +)) + +/// Corresponds to `androidx.core.app.NotificationChannelCompat` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat +class NotificationChannel { + /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder + NotificationChannel({ + required this.id, + required this.importance, + this.name, + this.lightsEnabled, + this.soundUrl, + this.vibrationPattern, + }); + + final String id; + + /// Specifies the importance level of notifications + /// to be posted on this channel. + /// + /// Must be a valid constant from [NotificationImportance]. + final int importance; + + final String? name; + final bool? lightsEnabled; + final String? soundUrl; + final Int64List? vibrationPattern; +} + +/// Corresponds to `android.content.Intent` +/// +/// See: +/// https://developer.android.com/reference/android/content/Intent +/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) +class AndroidIntent { + AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); + + final String action; + final String dataUrl; + + /// A combination of flags from [IntentFlag]. + final int flags; +} + +/// Corresponds to `android.app.PendingIntent`. +/// +/// See: https://developer.android.com/reference/android/app/PendingIntent +class PendingIntent { + /// Corresponds to `PendingIntent.getActivity`. + PendingIntent({required this.requestCode, required this.intent, required this.flags}); + + final int requestCode; + final AndroidIntent intent; + + /// A combination of flags from [PendingIntent.flags], and others associated + /// with `Intent`; see Android docs for `PendingIntent.getActivity`. + final int flags; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle +class InboxStyle { + InboxStyle({required this.summaryText}); + + final String summaryText; +} + +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + required this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + final Uint8List? iconBitmap; + + final String key; + final String name; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + final String text; + final int timestampMs; + final Person person; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + required this.conversationTitle, + required this.isGroupConversation, + required this.messages, + }); + + final Person user; + final String? conversationTitle; + final List messages; + final bool isGroupConversation; +} + +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({required this.group, required this.extras}); + + final String group; + final Map extras; + // Various other properties too; add them if needed. +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.tag, required this.notification}); + + final int id; + final String tag; + final Notification notification; + + // Ignore `groupKey` and `key`. While the `.groupKey` contains the + // `.notification.group`, and the `.key` contains the `.id` and `.tag`, + // they also have more stuff added on (and their structure doesn't seem to + // be documented.) + // final String? groupKey; + // final String? key; + + // Various other properties too; add them if needed. +} + +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + final String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + final bool isOwned; + + /// A `content://…` URL pointing to the sound file. + final String contentUrl; +} + +@HostApi() +abstract class AndroidNotificationHostApi { + /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) + void createNotificationChannel(NotificationChannel channel); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() + List getNotificationChannels(); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) + void deleteNotificationChannel(String channelId); + + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + List listStoredSoundsInNotificationsDirectory(); + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); + + /// Corresponds to `android.app.NotificationManager.notify`, + /// combined with `androidx.core.app.NotificationCompat.Builder`. + /// + /// The arguments `tag` and `id` go to the `notify` call. + /// The rest go to method calls on the builder. + /// + /// The `color` should be in the form 0xAARRGGBB. + /// See [ColorExtension.argbInt]. + /// + /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` + /// to get a resource ID to pass to `Builder.setSmallIcon`. + /// Whatever name is passed there must appear in keep.xml too: + /// see https://github.com/zulip/zulip-flutter/issues/528 . + /// + /// See: + /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify + /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder + // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. + // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. + // https://github.com/flutter/flutter/issues/134777 + void notify({ + String? tag, + required int id, + + // The remaining arguments go to method calls on NotificationCompat.Builder. + bool? autoCancel, + required String channelId, + int? color, + PendingIntent? contentIntent, + String? contentText, + String? contentTitle, + Map? extras, + String? groupKey, + InboxStyle? inboxStyle, + bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, + String? smallIconResourceName, + // NotificationCompat.Builder has lots more methods; add as needed. + // Keep them alphabetized, for easy comparison with that class's docs. + }); + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) + MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + List getActiveNotifications({required List desiredExtras}); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + void cancel({String? tag, required int id}); +} diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 708ae4efb5..66c1bd2e71 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -3,304 +3,51 @@ import 'package:pigeon/pigeon.dart'; // To rebuild this pigeon's output after editing this file, // run `tools/check pigeon --fix`. @ConfigurePigeon(PigeonOptions( - dartOut: 'lib/host/android_notifications.g.dart', - kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt', - kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), + dartOut: 'lib/host/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', )) -/// Corresponds to `androidx.core.app.NotificationChannelCompat` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat -class NotificationChannel { - /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder - NotificationChannel({ - required this.id, - required this.importance, - this.name, - this.lightsEnabled, - this.soundUrl, - this.vibrationPattern, - }); - - final String id; +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); - /// Specifies the importance level of notifications - /// to be posted on this channel. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// Must be a valid constant from [NotificationImportance]. - final int importance; - - final String? name; - final bool? lightsEnabled; - final String? soundUrl; - final Int64List? vibrationPattern; -} - -/// Corresponds to `android.content.Intent` -/// -/// See: -/// https://developer.android.com/reference/android/content/Intent -/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) -class AndroidIntent { - AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); - - final String action; - final String dataUrl; - - /// A combination of flags from [IntentFlag]. - final int flags; -} - -/// Corresponds to `android.app.PendingIntent`. -/// -/// See: https://developer.android.com/reference/android/app/PendingIntent -class PendingIntent { - /// Corresponds to `PendingIntent.getActivity`. - PendingIntent({required this.requestCode, required this.intent, required this.flags}); - - final int requestCode; - final AndroidIntent intent; - - /// A combination of flags from [PendingIntent.flags], and others associated - /// with `Intent`; see Android docs for `PendingIntent.getActivity`. - final int flags; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle -class InboxStyle { - InboxStyle({required this.summaryText}); - - final String summaryText; + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; } -/// Corresponds to `androidx.core.app.Person` -/// -/// See: https://developer.android.com/reference/androidx/core/app/Person -class Person { - Person({ - required this.iconBitmap, - required this.key, - required this.name, - }); +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); - /// An icon for this person. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// This should be compressed image data, in a format to be passed - /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. - /// Supported formats include JPEG, PNG, and WEBP. - /// - /// See: - /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) - final Uint8List? iconBitmap; - - final String key; - final String name; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message -class MessagingStyleMessage { - MessagingStyleMessage({ - required this.text, - required this.timestampMs, - required this.person, - }); - - final String text; - final int timestampMs; - final Person person; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle -class MessagingStyle { - MessagingStyle({ - required this.user, - required this.conversationTitle, - required this.isGroupConversation, - required this.messages, - }); - - final Person user; - final String? conversationTitle; - final List messages; - final bool isGroupConversation; -} - -/// Corresponds to `android.app.Notification` -/// -/// See: https://developer.android.com/reference/kotlin/android/app/Notification -class Notification { - Notification({required this.group, required this.extras}); - - final String group; - final Map extras; - // Various other properties too; add them if needed. -} - -/// Corresponds to `android.service.notification.StatusBarNotification` -/// -/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification -class StatusBarNotification { - StatusBarNotification({required this.id, required this.tag, required this.notification}); - - final int id; - final String tag; - final Notification notification; - - // Ignore `groupKey` and `key`. While the `.groupKey` contains the - // `.notification.group`, and the `.key` contains the `.id` and `.tag`, - // they also have more stuff added on (and their structure doesn't seem to - // be documented.) - // final String? groupKey; - // final String? key; - - // Various other properties too; add them if needed. -} - -/// Represents details about a notification sound stored in the -/// shared media store. -/// -/// Returned as a list entry by -/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. -class StoredNotificationSound { - StoredNotificationSound({ - required this.fileName, - required this.isOwned, - required this.contentUrl, - }); - - /// The display name of the sound file. - final String fileName; - - /// Specifies whether this file was created by the app. - /// - /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the - /// metadata matches the app's package name. - final bool isOwned; - - /// A `content://…` URL pointing to the sound file. - final String contentUrl; + /// See [notificationTapEvents]. + final Map payload; } @HostApi() -abstract class AndroidNotificationHostApi { - /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) - void createNotificationChannel(NotificationChannel channel); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - List getNotificationChannels(); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) - void deleteNotificationChannel(String channelId); - - /// The list of notification sound files present under `Notifications/Zulip/` - /// in the device's shared media storage, - /// found with `android.content.ContentResolver.query`. - /// - /// This is a complex ad-hoc method. - /// For detailed behavior, see its implementation. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) - List listStoredSoundsInNotificationsDirectory(); - - /// Wraps `android.content.ContentResolver.insert` combined with - /// `android.content.ContentResolver.openOutputStream` and - /// `android.content.res.Resources.openRawResource`. - /// - /// Copies a raw resource audio file to `Notifications/Zulip/` - /// directory in device's shared media storage. Returns the URL - /// of the target file in media store. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: - /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) - /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) - /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) - String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); - - /// Corresponds to `android.app.NotificationManager.notify`, - /// combined with `androidx.core.app.NotificationCompat.Builder`. - /// - /// The arguments `tag` and `id` go to the `notify` call. - /// The rest go to method calls on the builder. - /// - /// The `color` should be in the form 0xAARRGGBB. - /// See [ColorExtension.argbInt]. - /// - /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` - /// to get a resource ID to pass to `Builder.setSmallIcon`. - /// Whatever name is passed there must appear in keep.xml too: - /// see https://github.com/zulip/zulip-flutter/issues/528 . - /// - /// See: - /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify - /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. - // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. - // https://github.com/flutter/flutter/issues/134777 - void notify({ - String? tag, - required int id, - - // The remaining arguments go to method calls on NotificationCompat.Builder. - bool? autoCancel, - required String channelId, - int? color, - PendingIntent? contentIntent, - String? contentText, - String? contentTitle, - Map? extras, - String? groupKey, - InboxStyle? inboxStyle, - bool? isGroupSummary, - MessagingStyle? messagingStyle, - int? number, - String? smallIconResourceName, - // NotificationCompat.Builder has lots more methods; add as needed. - // Keep them alphabetized, for easy comparison with that class's docs. - }); - - /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, - /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. - /// - /// Returns the messaging style, if any, of an active notification - /// that has tag `tag`. If there are several such notifications, - /// an arbitrary one of them is used. - /// Returns null if there are no such notifications. - /// - /// See: - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) - MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. - /// - /// The keys of entries to fetch from notification's extras bundle must be - /// specified in the [desiredExtras] list. If this list is empty, then - /// [Notifications.extras] will also be empty. If value of the matched entry - /// is not of type string or is null, then that entry will be skipped. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() - List getActiveNotifications({required List desiredExtras}); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) - void cancel({String? tag, required int id}); +abstract class NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + NotificationDataFromLaunch? getNotificationDataFromLaunch(); +} + +@EventChannelApi() +abstract class NotificationEventChannelApi { + /// An event stream that emits a notification payload when the app + /// encounters a notification tap, while the app is running. + /// + /// Emits an event when + /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets + /// called, indicating that the user has tapped on a notification. The + /// emitted payload will be the raw APNs data dictionary from the + /// `UNNotificationResponse` passed to that method. + NotificationTapEvent notificationTapEvents(); } diff --git a/pubspec.lock b/pubspec.lock index a1a7177bc2..e3025cdc6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "80.0.0" + version: "82.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 + sha256: dda4fd7909a732a014239009aa52537b136f8ce568de23c212587097887e2307 url: "https://pub.dev" source: hosted - version: "1.3.54" + version: "1.3.56" analyzer: dependency: transitive description: name: analyzer - sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.4.5" app_settings: dependency: "direct main" description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.10.1" characters: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" checks: dependency: "direct dev" description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.14.1" cross_file: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.0" dbus: dependency: transitive description: @@ -262,10 +262,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "306b78788d1bb569edb7c55d622953c2414ca12445b41c9117963e03afc5c513" + sha256: "0c6396126421b590089447154c5f98a5de423b70cfb15b1578fd018843ee6f53" url: "https://pub.dev" source: hosted - version: "11.3.3" + version: "11.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -278,18 +278,18 @@ packages: dependency: "direct main" description: name: drift - sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5" + sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85 url: "https://pub.dev" source: hosted - version: "2.26.0" + version: "2.26.1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588" + sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816" url: "https://pub.dev" source: hosted - version: "2.26.0" + version: "2.26.1" fake_async: dependency: "direct dev" description: @@ -318,10 +318,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "8986dec4581b4bcd4b6df5d75a2ea0bede3db802f500635d05fa8be298f9467f" + sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "10.2.0" file_selector_linux: dependency: transitive description: @@ -334,10 +334,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.4+3" file_selector_platform_interface: dependency: transitive description: @@ -358,10 +358,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" + sha256: "420d9111dcf095341f1ea8fdce926eef750cf7b9745d21f38000de780c94f608" url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.14.0" firebase_core_platform_interface: dependency: transitive description: @@ -374,34 +374,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" + sha256: ddd72baa6f727e5b23f32d9af23d7d453d67946f380bd9c21daf474ee0f7326e url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "2.23.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "5f8918848ee0c8eb172fc7698619b2bcd7dda9ade8b93522c6297dd8f9178356" + sha256: "758461f67b96aa5ad27625aaae39882fd6d1961b1c7e005301f9a74b6336100b" url: "https://pub.dev" source: hosted - version: "15.2.5" + version: "15.2.7" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "0bbea00680249595fc896e7313a2bd90bd55be6e0abbe8b9a39d81b6b306acb6" + sha256: "614db1b0df0f53e541e41cc182b6d7ede5763c400f6ba232a5f8d0e1b5e5de32" url: "https://pub.dev" source: hosted - version: "4.6.5" + version: "4.6.7" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: ffb392ce2a7e8439cd0a9a80e3c702194e73c927e5c7b4f0adf6faa00b245b17 + sha256: b5fbbcdd3e0e7f3fde72b0c119410f22737638fed5fc428b54bba06bc1455d81 url: "https://pub.dev" source: hosted - version: "3.10.5" + version: "3.10.7" fixnum: dependency: transitive description: @@ -441,10 +441,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -454,10 +454,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.28" flutter_test: dependency: "direct dev" description: flutter @@ -501,18 +501,18 @@ packages: dependency: "direct main" description: name: html - sha256: "9475be233c437f0e3637af55e7702cbbe5c23a68bd56e8a5fa2d426297b7c6c8" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5+1" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -541,10 +541,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" + sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" url: "https://pub.dev" source: hosted - version: "0.8.12+22" + version: "0.8.12+23" image_picker_for_web: dependency: transitive description: @@ -642,10 +642,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "81f04dee10969f89f604e1249382d46b97a1ccad53872875369622b5bfc9e58a" + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.9.4" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -682,10 +682,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.0.0" logging: dependency: transitive description: @@ -786,10 +786,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.16" + version: "2.2.17" path_provider_foundation: dependency: transitive description: @@ -834,10 +834,10 @@ packages: dependency: "direct dev" description: name: pigeon - sha256: "3e4e6258f22760fa11f86d2a5202fb3f8367cb361d33bd9a93de85a7959e9976" + sha256: a093af76026160bb5ff6eb98e3e678a301ffd1001ac0d90be558bc133a0c73f5 url: "https://pub.dev" source: hosted - version: "25.3.1" + version: "25.3.2" platform: dependency: transitive description: @@ -874,10 +874,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.4" pub_semver: dependency: transitive description: @@ -906,18 +906,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "11.0.0" share_plus_platform_interface: dependency: "direct main" description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.0" shelf: dependency: transitive description: @@ -1007,18 +1007,18 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 url: "https://pub.dev" source: hosted - version: "2.7.5" + version: "2.7.6" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" + sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 url: "https://pub.dev" source: hosted - version: "0.5.32" + version: "0.5.34" sqlparser: dependency: transitive description: @@ -1079,26 +1079,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.26.2" test_api: dependency: "direct dev" description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.11" timing: dependency: transitive description: @@ -1127,10 +1127,10 @@ packages: dependency: "direct main" description: name: url_launcher_android - sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.3.15" + version: "6.3.16" url_launcher_ios: dependency: transitive description: @@ -1167,10 +1167,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -1191,10 +1191,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: @@ -1207,18 +1207,18 @@ packages: dependency: transitive description: name: video_player_android - sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a + sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.8.7" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" + sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.7.1" video_player_platform_interface: dependency: "direct dev" description: @@ -1231,42 +1231,42 @@ packages: dependency: transitive description: name: video_player_web - sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" + sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: b90fbcc8d7bdf3b883ea9706d9d76b9978cb1dfa4351fcc8014d6ec31a493354 + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 url: "https://pub.dev" source: hosted - version: "1.2.11" + version: "1.3.2" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web: dependency: transitive description: @@ -1279,18 +1279,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: @@ -1311,10 +1311,10 @@ packages: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.14.0" win32_registry: dependency: transitive description: @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.9.0-63.0.dev <4.0.0" - flutter: ">=3.32.0-1.0.pre.332" + dart: ">=3.9.0-220.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.465" diff --git a/pubspec.yaml b/pubspec.yaml index a1c9f6dd2d..029f2147ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,14 +8,14 @@ description: A Zulip client for Android and iOS publish_to: 'none' # Keep the last two numbers equal; see docs/release.md. -version: 0.0.28+28 +version: 30.0.259+259 environment: # We use a recent version of Flutter from its main channel, and # the corresponding recent version of the Dart SDK. # Feel free to update these regularly; see README.md for instructions. - sdk: '>=3.9.0-63.0.dev <4.0.0' - flutter: '>=3.32.0-1.0.pre.332' # adae8bbdbaed53ef305726fcfe811b2351d73a1a + sdk: '>=3.9.0-220.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.465' # ee089d09b21ec3ccc20d179c5be100d2a9d9f866 # To update dependencies, see instructions in README.md. dependencies: @@ -55,13 +55,13 @@ dependencies: package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.0.13 - share_plus: ^10.1.3 - share_plus_platform_interface: ^5.0.2 + share_plus: ^11.0.0 + share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" - video_player: ^2.8.3 + video_player: 2.9.5 # TODO unpin and upgrade to latest version wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin @@ -98,7 +98,7 @@ dev_dependencies: drift_dev: ^2.5.2 fake_async: ^1.3.1 flutter_checks: ^0.1.2 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 ini: ^2.1.0 json_serializable: ^6.5.4 legacy_checks: ^0.1.0 @@ -115,6 +115,7 @@ flutter: uses-material-design: true assets: + - assets/KaTeX/LICENSE - assets/Noto_Color_Emoji/LICENSE - assets/Pygments/AUTHORS.txt - assets/Pygments/LICENSE.txt diff --git a/shell.nix b/shell.nix index 7404f88f6c..e286f2d4c1 100644 --- a/shell.nix +++ b/shell.nix @@ -12,7 +12,7 @@ mkShell { gtk3 # Curiously `nix-env -i` can't handle this one adequately. # But `nix-shell` on this shell.nix does fine. pcre - epoxy + libepoxy # This group all seem not strictly necessary -- commands like # `flutter run -d linux` seem to *work* fine without them, but @@ -34,9 +34,11 @@ mkShell { xorg.libXtst.out pcre2.dev - jdk11 + jdk17 android-studio android-tools + + nodejs ]; LD_LIBRARY_PATH = lib.makeLibraryPath [ diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 2b230376ac..2382ab859b 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -81,6 +81,10 @@ class FakeHttpClient extends http.BaseClient { } } + void clearPreparedResponses() { + _preparedResponses.clear(); + } + @override Future send(http.BaseRequest request) { _requestHistory.add(request); @@ -278,6 +282,10 @@ class FakeApiConnection extends ApiConnection { delay: delay, ); } + + void clearPreparedResponses() { + client.clearPreparedResponses(); + } } extension FakeApiConnectionChecks on Subject { diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 8791c1b9d9..f882233993 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -21,18 +21,31 @@ extension UserChecks on Subject { Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot'); } +extension SavedSnippetChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get title => has((x) => x.title, 'title'); + Subject get content => has((x) => x.content, 'content'); + Subject get dateCreated => has((x) => x.dateCreated, 'dateCreated'); +} + extension ZulipStreamChecks on Subject { } extension TopicNameChecks on Subject { Subject get apiName => has((x) => x.apiName, 'apiName'); - Subject get displayName => has((x) => x.displayName, 'displayName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); } 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'); } +extension DmConversationChecks on Subject { + Subject> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds'); +} + extension MessageBaseChecks on Subject> { Subject get id => has((e) => e.id, 'id'); Subject get senderId => has((e) => e.senderId, 'senderId'); @@ -56,8 +69,6 @@ extension MessageChecks on Subject { Subject get poll => has((e) => e.poll, 'poll'); Subject get type => has((e) => e.type, 'type'); Subject> get flags => has((e) => e.flags, 'flags'); - Subject get matchContent => has((e) => e.matchContent, 'matchContent'); - Subject get matchTopic => has((e) => e.matchTopic, 'matchTopic'); } extension StreamMessageChecks on Subject { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b1552deb5b..7ace2326ee 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -93,13 +93,6 @@ void main() { .topic.equals(const TopicName('hello')); }); - test('match_subject -> matchTopic', () { - check(baseStreamJson()).not((it) => it.containsKey('match_topic')); - check(Message.fromJson(baseStreamJson() - ..['match_subject'] = 'yo' - )).matchTopic.equals('yo'); - }); - test('no crash on unrecognized flag', () { final m1 = Message.fromJson( (deepToJson(eg.streamMessage()) as Map) @@ -161,6 +154,31 @@ void main() { doCheck(eg.t('✔ a'), eg.t('✔ b'), false); }); + + test('processLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(topic.processLikeServer( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).equals(expected); + } + + check(() => eg.t('').processLikeServer( + zulipFeatureLevel: 333, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).throws(); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 333); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333); + doCheck(eg.t('other topic'), eg.t('other topic'), 333); + + doCheck(eg.t(''), eg.t(''), 334); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 334); + doCheck(eg.t(emptyTopicDisplayName), eg.t(''), 334); + doCheck(eg.t('other topic'), eg.t('other topic'), 334); + + doCheck(eg.t('(no topic)'), eg.t(''), 370); + }); }); group('DmMessage', () { diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 416fca4f3b..f00bf4428f 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -20,10 +20,12 @@ void main() { required bool expectLegacy, required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) async { final result = await getMessageCompat(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); if (expectLegacy) { check(connection.lastRequest).isA() @@ -35,6 +37,7 @@ void main() { 'num_before': '0', 'num_after': '0', if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), 'client_gravatar': 'true', }); } else { @@ -43,6 +46,7 @@ void main() { ..url.path.equals('/api/v1/messages/$messageId') ..url.queryParameters.deepEquals({ if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } return result; @@ -57,6 +61,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNotNull().jsonEquals(message); }); @@ -71,6 +76,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNull(); }); @@ -92,6 +98,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNotNull().jsonEquals(message); }); @@ -113,6 +120,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNull(); }); @@ -124,11 +132,13 @@ void main() { FakeApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -145,7 +155,11 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: true, - expected: {'apply_markdown': 'true'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'true', + 'allow_empty_topic_name': 'true', + }); }); }); @@ -155,7 +169,21 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: false, - expected: {'apply_markdown': 'false'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'false', + 'allow_empty_topic_name': 'true', + }); + }); + }); + + test('allow empty topic name', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: fakeResult.toJson()); + await checkGetMessage(connection, + messageId: 1, + allowEmptyTopicName: true, + expected: {'allow_empty_topic_name': 'true'}); }); }); @@ -164,6 +192,7 @@ void main() { connection.prepare(json: fakeResult.toJson()); check(() => getMessage(connection, messageId: 1, + allowEmptyTopicName: true, )).throws(); }); }); @@ -255,12 +284,14 @@ void main() { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessages(connection, narrow: narrow, anchor: anchor, includeAnchor: includeAnchor, numBefore: numBefore, numAfter: numAfter, clientGravatar: clientGravatar, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -279,11 +310,13 @@ void main() { await checkGetMessages(connection, narrow: const CombinedFeedNarrow().apiEncode(), anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -294,6 +327,7 @@ void main() { await checkGetMessages(connection, narrow: [ApiNarrowDm([123, 234])], anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([ {'operator': 'pm-with', 'operand': [123, 234]}, @@ -301,6 +335,7 @@ void main() { 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -312,11 +347,13 @@ void main() { narrow: const CombinedFeedNarrow().apiEncode(), anchor: const NumericAnchor(42), numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': '42', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -454,6 +491,7 @@ void main() { bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, required Map expected, }) async { @@ -464,6 +502,7 @@ void main() { sendNotificationToOldThread: sendNotificationToOldThread, sendNotificationToNewThread: sendNotificationToNewThread, content: content, + prevContentSha256: prevContentSha256, streamId: streamId, ); check(connection.lastRequest).isA() @@ -473,6 +512,20 @@ void main() { return result; } + test('pure content change', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: UpdateMessageResult().toJson()); + await checkUpdateMessage(connection, + messageId: eg.streamMessage().id, + content: 'asdf', + prevContentSha256: '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + expected: { + 'content': 'asdf', + 'prev_content_sha256': '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + }); + }); + }); + test('topic/content change', () { // A separate test exercises `streamId`; // the API doesn't allow changing channel and content at the same time. @@ -776,76 +829,4 @@ void main() { }); }); }); - - group('markAllAsRead', () { - Future checkMarkAllAsRead( - FakeApiConnection connection, { - required Map expected, - }) async { - connection.prepare(json: {}); - await markAllAsRead(connection); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkAllAsRead(connection, expected: {}); - }); - }); - }); - - group('markStreamAsRead', () { - Future checkMarkStreamAsRead( - FakeApiConnection connection, { - required int streamId, - required Map expected, - }) async { - connection.prepare(json: {}); - await markStreamAsRead(connection, streamId: streamId); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkStreamAsRead(connection, - streamId: 10, - expected: {'stream_id': '10'}); - }); - }); - }); - - group('markTopicAsRead', () { - Future checkMarkTopicAsRead( - FakeApiConnection connection, { - required int streamId, - required String topicName, - required Map expected, - }) async { - connection.prepare(json: {}); - await markTopicAsRead(connection, - streamId: streamId, topicName: eg.t(topicName)); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkTopicAsRead(connection, - streamId: 10, - topicName: 'topic', - expected: { - 'stream_id': '10', - 'topic_name': 'topic', - }); - }); - }); - }); } diff --git a/test/api/route/realm_test.dart b/test/api/route/realm_test.dart index c1cc18b98b..5d11a9d51f 100644 --- a/test/api/route/realm_test.dart +++ b/test/api/route/realm_test.dart @@ -22,7 +22,7 @@ void main() { } final fakeResult = ServerEmojiData(codeToNames: { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }); diff --git a/test/api/route/route_checks.dart b/test/api/route/route_checks.dart index 6d310ab200..1ecd90e9c8 100644 --- a/test/api/route/route_checks.dart +++ b/test/api/route/route_checks.dart @@ -1,8 +1,12 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; extension SendMessageResultChecks on Subject { Subject get id => has((e) => e.id, 'id'); } +extension CreateSavedSnippetResultChecks on Subject { + Subject get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId'); +} // TODO add similar extensions for other classes in api/route/*.dart diff --git a/test/api/route/saved_snippets_test.dart b/test/api/route/saved_snippets_test.dart new file mode 100644 index 0000000000..3eeccbde8b --- /dev/null +++ b/test/api/route/saved_snippets_test.dart @@ -0,0 +1,27 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; +import 'route_checks.dart'; + +void main() { + test('smoke', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare( + json: CreateSavedSnippetResult(savedSnippetId: 123).toJson()); + final result = await createSavedSnippet(connection, + title: 'test saved snippet', content: 'content'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/saved_snippets') + ..bodyFields.deepEquals({ + 'title': 'test saved snippet', + 'content': 'content', + }); + check(result).savedSnippetId.equals(123); + }); + }); +} diff --git a/test/api/route/users_test.dart b/test/api/route/users_test.dart new file mode 100644 index 0000000000..b83c801a2a --- /dev/null +++ b/test/api/route/users_test.dart @@ -0,0 +1,39 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/users.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + test('smoke updatePresence', () { + return FakeApiConnection.with_((connection) async { + final response = UpdatePresenceResult( + presenceLastUpdateId: -1, + serverTimestamp: 1656958539.6287155, + presences: {}, + ); + connection.prepare(json: response.toJson()); + await updatePresence(connection, + lastUpdateId: -1, + historyLimitDays: 21, + newUserInput: false, + pingOnly: false, + status: PresenceStatus.active, + ); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/presence') + ..bodyFields.deepEquals({ + 'last_update_id': '-1', + 'history_limit_days': '21', + 'new_user_input': 'false', + 'ping_only': 'false', + 'status': 'active', + 'slim_presence': 'true', + }); + }); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index fc3acfc5a4..851f3a18e2 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'; @@ -69,6 +70,20 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } +//////////////////////////////////////////////////////////////// +// Time values. +// + +final timeInPast = DateTime.utc(2025, 4, 1, 8, 30, 0); + +/// The UNIX timestamp, in UTC seconds. +/// +/// This is the commonly used format in the Zulip API for timestamps. +int utcTimestamp([DateTime? dateTime]) { + dateTime ??= timeInPast; + return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; +} + //////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // @@ -77,7 +92,7 @@ final Uri realmUrl = Uri.parse('https://chat.example/'); Uri get _realmUrl => realmUrl; const String recentZulipVersion = '9.0'; -const int recentZulipFeatureLevel = 278; +const int recentZulipFeatureLevel = 382; const int futureZulipFeatureLevel = 9999; const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; @@ -115,6 +130,42 @@ GetServerSettingsResult serverSettings({ ); } +ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['slight_smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + +ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { + final a = serverEmojiDataPopular; + final b = data; + final result = ServerEmojiData( + codeToNames: {...a.codeToNames, ...b.codeToNames}, + ); + assert( + result.codeToNames.length == a.codeToNames.length + b.codeToNames.length, + 'eg.serverEmojiDataPopularPlus called with data that collides with eg.serverEmojiDataPopular', + ); + return result; +} + +/// Like [serverEmojiDataPopular], but with the legacy '1f642': ['smile'] +/// instead of '1f642': ['slight_smile']; see zulip/zulip@9feba0f16f. +/// +/// zulip/zulip@9feba0f16f is a Server 11 commit. +// TODO(server-11) can drop this +ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + RealmEmojiItem realmEmojiItem({ required String emojiCode, required String emojiName, @@ -236,6 +287,28 @@ final User thirdUser = user(fullName: 'Third User'); final User fourthUser = user(fullName: 'Fourth User'); +//////////////////////////////////////////////////////////////// +// Data attached to the self-account on the realm +// + +int _nextSavedSnippetId() => _lastSavedSnippetId++; +int _lastSavedSnippetId = 1; + +SavedSnippet savedSnippet({ + int? id, + String? title, + String? content, + int? dateCreated, +}) { + _checkPositive(id, 'saved snippet ID'); + return SavedSnippet( + id: id ?? _nextSavedSnippetId(), + title: title ?? 'A saved snippet', + content: content ?? 'foo bar baz', + dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp + ); +} + //////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -516,20 +589,81 @@ DmMessage dmMessage({ }) as Map); } -/// A GetMessagesResult the server might return on an `anchor=newest` request. +/// A GetMessagesResult the server might return for +/// a request that sent the given [anchor]. +/// +/// The request's anchor controls the response's [GetMessagesResult.anchor], +/// affects the default for [foundAnchor], +/// and in some cases forces the value of [foundOldest] or [foundNewest]. +GetMessagesResult getMessagesResult({ + required Anchor anchor, + bool? foundAnchor, + bool? foundOldest, + bool? foundNewest, + bool historyLimited = false, + required List messages, +}) { + final resultAnchor = switch (anchor) { + AnchorCode.oldest => 0, + NumericAnchor(:final messageId) => messageId, + AnchorCode.firstUnread => + throw ArgumentError("firstUnread not accepted in this helper; try NumericAnchor"), + AnchorCode.newest => 10_000_000_000_000_000, // that's 16 zeros + }; + + switch (anchor) { + case AnchorCode.oldest || AnchorCode.newest: + assert(foundAnchor == null); + foundAnchor = false; + case AnchorCode.firstUnread || NumericAnchor(): + foundAnchor ??= true; + } + + if (anchor == AnchorCode.oldest) { + assert(foundOldest == null); + foundOldest = true; + } else if (anchor == AnchorCode.newest) { + assert(foundNewest == null); + foundNewest = true; + } + if (foundOldest == null || foundNewest == null) throw ArgumentError(); + + return GetMessagesResult( + anchor: resultAnchor, + foundAnchor: foundAnchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + +/// A GetMessagesResult the server might return on an `anchor=newest` request, +/// or `anchor=first_unread` when there are no unreads. GetMessagesResult newestGetMessagesResult({ required bool foundOldest, bool historyLimited = false, required List messages, }) { - return GetMessagesResult( - // These anchor, foundAnchor, and foundNewest values are what the server - // appears to always return when the request had `anchor=newest`. - anchor: 10000000000000000, // that's 16 zeros - foundAnchor: false, - foundNewest: true, + return getMessagesResult(anchor: AnchorCode.newest, foundOldest: foundOldest, + historyLimited: historyLimited, messages: messages); +} +/// A GetMessagesResult the server might return on an initial request +/// when the anchor is in the middle of history (e.g., a /near/ link). +GetMessagesResult nearGetMessagesResult({ + required int anchor, + bool foundAnchor = true, + required bool foundOldest, + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, foundOldest: foundOldest, + foundNewest: foundNewest, historyLimited: historyLimited, messages: messages, ); @@ -553,6 +687,63 @@ GetMessagesResult olderGetMessagesResult({ ); } +/// A GetMessagesResult the server might return when we request newer messages. +GetMessagesResult newerGetMessagesResult({ + required int anchor, + bool foundAnchor = false, // the value if the server understood includeAnchor false + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, + foundOldest: false, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + +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, @@ -623,8 +814,13 @@ UserTopicEvent userTopicEvent( ); } -MessageEvent messageEvent(Message message) => - MessageEvent(id: 0, message: message, localMessageId: null); +MutedUsersEvent mutedUsersEvent(List userIds) { + return MutedUsersEvent(id: 1, + mutedUsers: userIds.map((id) => MutedUserItem(id: id)).toList()); +} + +MessageEvent messageEvent(Message message, {int? localMessageId}) => + MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); DeleteMessageEvent deleteMessageEvent(List messages) { assert(messages.isNotEmpty); @@ -904,11 +1100,16 @@ InitialSnapshot initialSnapshot({ List? alertWords, List? customProfileFields, EmailAddressVisibility? emailAddressVisibility, + int? serverPresencePingIntervalSeconds, + int? serverPresenceOfflineThresholdSeconds, int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, + List? mutedUsers, + Map? presences, Map? realmEmoji, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, @@ -919,6 +1120,7 @@ InitialSnapshot initialSnapshot({ int? realmWaitingPeriodThreshold, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, @@ -936,14 +1138,19 @@ InitialSnapshot initialSnapshot({ alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], emailAddressVisibility: emailAddressVisibility ?? EmailAddressVisibility.everyone, + serverPresencePingIntervalSeconds: serverPresencePingIntervalSeconds ?? 60, + serverPresenceOfflineThresholdSeconds: serverPresenceOfflineThresholdSeconds ?? 140, serverTypingStartedExpiryPeriodMilliseconds: serverTypingStartedExpiryPeriodMilliseconds ?? 15000, serverTypingStoppedWaitPeriodMilliseconds: serverTypingStoppedWaitPeriodMilliseconds ?? 5000, serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, + mutedUsers: mutedUsers ?? [], + presences: presences ?? {}, realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], + savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default @@ -957,7 +1164,8 @@ InitialSnapshot initialSnapshot({ realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, - realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds ?? 600, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl 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/flutter_checks.dart b/test/flutter_checks.dart index df2777aac6..1bafd6636f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -83,6 +83,7 @@ extension TextStyleChecks on Subject { Subject get inherit => has((t) => t.inherit, 'inherit'); Subject get color => has((t) => t.color, 'color'); Subject get fontSize => has((t) => t.fontSize, 'fontSize'); + Subject get fontStyle => has((t) => t.fontStyle, 'fontStyle'); Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); Subject get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing'); Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); @@ -142,6 +143,10 @@ extension TextEditingControllerChecks on Subject { Subject get text => has((t) => t.text, 'text'); } +extension FocusNodeChecks on Subject { + Subject get hasFocus => has((t) => t.hasFocus, 'hasFocus'); +} + extension ScrollMetricsChecks on Subject { Subject get minScrollExtent => has((x) => x.minScrollExtent, 'minScrollExtent'); Subject get maxScrollExtent => has((x) => x.maxScrollExtent, 'maxScrollExtent'); @@ -228,6 +233,7 @@ extension ThemeDataChecks on Subject { extension InputDecorationChecks on Subject { Subject get hintText => has((x) => x.hintText, 'hintText'); + Subject get hintStyle => has((x) => x.hintStyle, 'hintStyle'); } extension TextFieldChecks on Subject { @@ -245,5 +251,7 @@ extension SwitchListTileChecks on Subject { } extension RadioListTileChecks on Subject> { + // TODO(#1545) stop using the deprecated member + // ignore: deprecated_member_use Subject get checked => has((x) => x.checked, 'checked'); } diff --git a/test/licenses_test.dart b/test/licenses_test.dart new file mode 100644 index 0000000000..8e5cf1fe33 --- /dev/null +++ b/test/licenses_test.dart @@ -0,0 +1,14 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/licenses.dart'; + +import 'fake_async.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('smoke: ensure all additional licenses load', () => awaitFakeAsync((async) async { + await check(additionalLicenses().toList()) + .completes((it) => it.isNotEmpty()); + })); +} diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 16b92d98d4..cab073db48 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -19,6 +20,7 @@ import 'package:zulip/widgets/compose_box.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -1026,6 +1028,21 @@ void main() { check(done).isTrue(); }); + test('TopicAutocompleteView getStreamTopics request', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: '')], + ).toJson()); + TopicAutocompleteView.init(store: store, streamId: 1000, + query: TopicAutocompleteQuery('foo')); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/1000/topics') + ..url.queryParameters['allow_empty_topic_name'].equals('true'); + }); + group('TopicAutocompleteQuery.testTopic', () { final store = eg.store(); void doCheck(String rawQuery, String topic, bool expected) { diff --git a/test/model/binding.dart b/test/model/binding.dart index 31f5738ddf..839242c1ce 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; @@ -241,6 +242,9 @@ class TestZulipBinding extends ZulipBinding { _closeInAppWebViewCallCount++; } + @override + DateTime utcNow() => clock.now().toUtc(); + @override Stopwatch stopwatch() => clock.stopwatch(); @@ -308,14 +312,18 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } + @override + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// @@ -753,6 +761,32 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } } +class FakeNotificationPigeonApi implements NotificationPigeonApi { + NotificationDataFromLaunch? _notificationDataFromLaunch; + + /// Populates the notification data for launch to be returned + /// by [getNotificationDataFromLaunch]. + void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) { + _notificationDataFromLaunch = data; + } + + @override + Future getNotificationDataFromLaunch() async => + _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } +} + typedef AndroidNotificationHostApiNotifyCall = ({ String? tag, int id, diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index bfbc170ca1..2031b69d28 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; @@ -225,26 +226,69 @@ hello group('mention', () { group('user', () { final user = eg.user(userId: 123, fullName: 'Full Name'); - test('not silent', () { + final message = eg.streamMessage(sender: user); + test('not silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: false)).equals('@**Full Name|123**'); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); }); - test('silent', () { + test('silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: true)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two users with same fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two same-name users but one of them is deactivated', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); + }); + + test('userMentionFromMessage, known user', () async { + final user = eg.user(userId: 123, fullName: 'Full Name'); + final store = eg.store(); + await store.addUser(user); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**New Name|123**'); + }); + + test('userMentionFromMessage, unknown user', () async { + final store = eg.store(); + check(store.getUser(user.userId)).isNull(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + }); + + test('userMentionFromMessage, muted user', () async { + final store = eg.store(); + await store.addUser(user); + await store.setMutedUsers([user.userId]); + check(store.isUserMuted(user.userId)).isTrue(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); // not replaced with 'Muted user' }); }); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5ab60c8e7e..f9b461e17c 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,9 +518,9 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -529,7 +529,7 @@ class ContentExample { ]), ])); - static final mathBlock = ContentExample( + static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", expectedText: r'\lambda', @@ -538,9 +538,9 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -549,7 +549,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInParagraph = ContentExample( + static const mathBlocksMultipleInParagraph = ContentExample( 'math blocks, multiple in paragraph', '```math\na\n\nb\n```', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490 @@ -563,9 +563,9 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -574,9 +574,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -586,7 +586,7 @@ class ContentExample { ]), ]); - static final mathBlockInQuote = ContentExample( + static const mathBlockInQuote = ContentExample( 'math block in quote', // There's sometimes a quirky extra `
\n` at the end of the `

` that // encloses the math block. In particular this happens when the math block @@ -602,9 +602,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -614,7 +614,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInQuote = ContentExample( + static const mathBlocksMultipleInQuote = ContentExample( 'math blocks, multiple in quote', "````quote\n```math\na\n\nb\n```\n````", // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2029236 @@ -631,9 +631,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -642,9 +642,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -654,7 +654,7 @@ class ContentExample { ]), ])]); - static final mathBlockBetweenImages = ContentExample( + static const mathBlockBetweenImages = ContentExample( 'math block between images', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891 'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg', @@ -680,9 +680,9 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(),text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -702,7 +702,7 @@ class ContentExample { // The font sizes can be compared using the katex.css generated // from katex.scss : // https://unpkg.com/katex@0.16.21/dist/katex.css - static final mathBlockKatexSizing = ContentExample( + static const mathBlockKatexSizing = ContentExample( 'math block; KaTeX different sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 '```math\n\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0\n```', @@ -727,51 +727,51 @@ class ContentExample { MathBlockNode( texSource: "\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0", nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 text: '2', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 text: '3', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 text: '4', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 text: '5', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 text: '6', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 text: '7', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 text: '8', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 text: '9', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 text: '0', nodes: null), @@ -779,7 +779,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexNestedSizing = ContentExample( + static const mathBlockKatexNestedSizing = ContentExample( 'math block; KaTeX nested sizing', '```math\n\\tiny {1 \\Huge 2}\n```', '

' @@ -796,23 +796,23 @@ class ContentExample { MathBlockNode( texSource: '\\tiny {1 \\Huge 2}', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 text: '2', nodes: null), @@ -821,7 +821,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexDelimSizing = ContentExample( + static const mathBlockKatexDelimSizing = ContentExample( 'math block; KaTeX delimiter sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 '```math\n⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊\n```', @@ -841,50 +841,50 @@ class ContentExample { MathBlockNode( texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles(heightEm: 3.0), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), text: '(', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), text: '[', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), text: '⌈', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), text: '⌊', nodes: null), @@ -1327,6 +1327,24 @@ class ContentExample { InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'), ]); + static const audioInline = ContentExample( + 'audio inline', + '![crab-rave.mp3](/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3)', + '

', [ + ParagraphNode(links: null, nodes: [ + LinkNode(url: '/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3', nodes: [TextNode('crab-rave.mp3')]), + ]), + ]); + + static const audioInlineNoTitle = ContentExample( + 'audio inline no title', + '![](/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3)', + '

', [ + ParagraphNode(links: null, nodes: [ + LinkNode(url: '/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3', nodes: [TextNode('crab-rave.mp3')]), + ]), + ]); + static const websitePreviewSmoke = ContentExample( 'website preview smoke', 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', @@ -1642,15 +1660,18 @@ UnimplementedInlineContentNode inlineUnimplemented(String html) { return UnimplementedInlineContentNode(htmlNode: fragment.nodes.single); } -void testParse(String name, String html, List nodes) { +void testParse(String name, String html, List nodes, { + Object? skip, +}) { test(name, () { check(parseContent(html)) .equalsNode(ZulipContent(nodes: nodes)); - }); + }, skip: skip); } -void testParseExample(ContentExample example) { - testParse('parse ${example.description}', example.html, example.expectedNodes); +void testParseExample(ContentExample example, {Object? skip}) { + testParse('parse ${example.description}', example.html, example.expectedNodes, + skip: skip); } void main() async { @@ -1960,7 +1981,10 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); + // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. + testParseExample(ContentExample.mathBlockKatexDelimSizing, skip: true); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); @@ -1984,6 +2008,9 @@ void main() async { testParseExample(ContentExample.videoInline); testParseExample(ContentExample.videoInlineClassesFlipped); + testParseExample(ContentExample.audioInline); + testParseExample(ContentExample.audioInlineNoTitle); + testParseExample(ContentExample.websitePreviewSmoke); testParseExample(ContentExample.websitePreviewWithoutTitle); testParseExample(ContentExample.websitePreviewWithoutDescription); @@ -2034,7 +2061,7 @@ void main() async { r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, - r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)\);', + r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', ).allMatches(source).map((m) => m.group(1)); check(testedExamples).unorderedEquals(declaredExamples); }, skip: Platform.isWindows, // [intended] purely analyzes source, so diff --git a/test/model/database_test.dart b/test/model/database_test.dart index e6e2b729be..e89bd569db 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -326,6 +326,8 @@ void main() { check(globalSettings.browserPreference).isNull(); await after.close(); }); + + // TODO(#1593) test upgrade to v9: legacyUpgradeState set to noLegacy }); } diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index 7916af0849..d96ccae5e4 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -10,12 +10,30 @@ import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; void main() { + PerAccountStore prepare({ + Map realmEmoji = const {}, + bool addServerDataForPopular = true, + Map>? unicodeEmoji, + }) { + final store = eg.store( + initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); + + if (addServerDataForPopular || unicodeEmoji != null) { + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + final emojiData = addServerDataForPopular + ? eg.serverEmojiDataPopularPlus(extraEmojiData) + : extraEmojiData; + store.setServerEmojiData(emojiData); + } + return store; + } + group('emojiDisplayFor', () { test('Unicode emoji', () { check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, - emojiCode: '1f642', emojiName: 'smile') + emojiCode: '1f642', emojiName: 'slight_smile') ).isA() - ..emojiName.equals('smile') + ..emojiName.equals('slight_smile') ..emojiUnicode.equals('🙂'); }); @@ -78,7 +96,10 @@ void main() { }); }); - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = ( + eg.store()..setServerEmojiData(eg.serverEmojiDataPopular) + ).popularEmojiCandidates(); + assert(popularCandidates.length == 6); Condition isUnicodeCandidate(String? emojiCode, List? names) { return (it_) { @@ -116,30 +137,11 @@ void main() { group('allEmojiCandidates', () { // TODO test emojiDisplay of candidates matches emojiDisplayFor - PerAccountStore prepare({ - Map realmEmoji = const {}, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); - } - return store; - } - - test('popular emoji appear even when no server emoji data', () { - final store = prepare(unicodeEmoji: null); - check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, - isZulipCandidate(), - ]); - }); - test('popular emoji appear in their canonical order', () { // In the server's emoji data, have the popular emoji in a permuted order, // and interspersed with other emoji. - final store = prepare(unicodeEmoji: { + assert(popularCandidates.length == 6); + final store = prepare(addServerDataForPopular: false, unicodeEmoji: { '1f603': ['smiley'], for (final candidate in popularCandidates.skip(3)) candidate.emojiCode: [candidate.emojiName, ...candidate.aliases], @@ -246,15 +248,16 @@ void main() { }); test('updates on setServerEmojiData', () { - final store = prepare(); + final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, isZulipCandidate(), ]); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f516': ['bookmark'], - })); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f516': ['bookmark'], + }))); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isUnicodeCandidate('1f516', ['bookmark']), @@ -290,6 +293,44 @@ void main() { }); }); + group('popularEmojiCandidates', () { + test('memoizes result, before setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isEmpty()..identicalTo(candidates); + }); + + test('memoizes result, after setServerEmojiData', () { + final store = prepare(); + check(store.debugServerEmojiData).isNotNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isNotEmpty()..identicalTo(candidates); + }); + + test('updates on first and subsequent setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + + final candidates1 = store.popularEmojiCandidates(); + check(candidates1).isEmpty(); + + store.setServerEmojiData(eg.serverEmojiDataPopularLegacy); + final candidates2 = store.popularEmojiCandidates(); + check(candidates2) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates1)); + + store.setServerEmojiData(eg.serverEmojiDataPopular); + final candidates3 = store.popularEmojiCandidates(); + check(candidates3) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates2)); + }); + }); + group('EmojiAutocompleteView', () { Condition isUnicodeResult({String? emojiCode, List? names}) { return (it) => it.isA().candidate.which( @@ -309,24 +350,11 @@ void main() { List> arePopularResults = popularCandidates.map( (c) => isUnicodeResult(emojiCode: c.emojiCode)).toList(); - PerAccountStore prepare({ - Map realmEmoji = const {}, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: { - for (final MapEntry(:key, :value) in realmEmoji.entries) - key: eg.realmEmojiItem(emojiCode: key, emojiName: value), - })); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); - } - return store; - } - test('results can include all three emoji types', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}, + ); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('')); bool done = false; @@ -343,7 +371,8 @@ void main() { test('results update after query change', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('hap')); bool done = false; @@ -354,16 +383,16 @@ void main() { isRealmResult(emojiName: 'happy')); done = false; - view.query = EmojiAutocompleteQuery('sm'); + view.query = EmojiAutocompleteQuery('bo'); await Future(() {}); check(done).isTrue(); check(view.results).single.which( - isUnicodeResult(names: ['smile'])); + isUnicodeResult(names: ['bookmark'])); }); Future> resultsOf( String query, { - Map realmEmoji = const {}, + Map realmEmoji = const {}, Map>? unicodeEmoji, }) async { final store = prepare(realmEmoji: realmEmoji, unicodeEmoji: unicodeEmoji); @@ -389,7 +418,7 @@ void main() { check(await resultsOf('')).deepEquals([ isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), isUnicodeResult(names: ['tada']), - isUnicodeResult(names: ['smile']), + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), isUnicodeResult(names: ['octopus']), @@ -402,6 +431,7 @@ void main() { isUnicodeResult(names: ['tada']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['octopus']), ]); @@ -412,6 +442,7 @@ void main() { isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), + isUnicodeResult(names: ['slight_smile']), ]); }); diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 611cc3ece0..824def8cc2 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -160,7 +160,14 @@ void main() { test(urlString, () async { final store = await setupStore(realmUrl: realmUrl, streams: streams, users: users); final url = store.tryResolveUrl(urlString)!; - check(parseInternalLink(url, store)).equals(expected); + final result = parseInternalLink(url, store); + if (expected == null) { + check(result).isNull(); + } else { + check(result).isA() + ..realmUrl.equals(realmUrl) + ..narrow.equals(expected); + } }); } } @@ -258,6 +265,9 @@ void main() { final url = store.tryResolveUrl(urlString)!; final result = parseInternalLink(url, store); check(result != null).equals(expected); + if (result != null) { + check(result).realmUrl.equals(realmUrl); + } }); } } @@ -370,6 +380,8 @@ void main() { } }); + // TODO(#1570): test parsing /near/ operator + group('unexpected link shapes are rejected', () { final testCases = [ ('/#narrow/stream/name/topic/', null), // missing operand @@ -564,3 +576,11 @@ void main() { }); }); } + +extension InternalLinkChecks on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); +} + +extension NarrowLinkChecks on Subject { + Subject get narrow => has((x) => x.narrow, 'narrow'); +} 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 f92bdfc096..7062aeffa9 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,6 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/backoff.dart'; @@ -8,8 +13,10 @@ import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; +import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/algorithms.dart'; import 'package:zulip/model/content.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'; @@ -19,14 +26,38 @@ import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; import 'content_checks.dart'; +import 'message_checks.dart'; import 'recent_senders_test.dart' as recent_senders_test; import 'test_store.dart'; const newestResult = eg.newestGetMessagesResult; +const nearResult = eg.nearGetMessagesResult; const olderResult = eg.olderGetMessagesResult; +const newerResult = eg.newerGetMessagesResult; void main() { + // Arrange for errors caught within the Flutter framework to be printed + // unconditionally, rather than throttled as they normally are in an app. + // + // When using `testWidgets` from flutter_test, this is done automatically; + // compare the [FlutterError.dumpErrorToConsole] call sites, + // and [FlutterError.onError=] and [debugPrint=] call sites, in flutter_test. + // + // This test file is unusual in needing this manual arrangement; it's needed + // because these aren't widget tests, and yet do have some failures arise as + // exceptions that get caught by the framework: namely, when [checkInvariants] + // throws from within an `addListener` callback. Those exceptions get caught + // by [ChangeNotifier.notifyListeners] and reported there through + // [FlutterError.reportError]. + debugPrint = debugPrintSynchronously; + FlutterError.onError = (details) { + FlutterError.dumpErrorToConsole(details, forceReport: true); + }; + + TestZulipBinding.ensureInitialized(); + // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -46,15 +77,19 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] 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(), + Anchor anchor = AnchorCode.newest, + ZulipStream? stream, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; notifiedCount = 0; - model = MessageListView.init(store: store, narrow: narrow) + model = MessageListView.init(store: store, narrow: narrow, anchor: anchor) ..addListener(() { checkInvariants(model); notifiedCount++; @@ -67,21 +102,49 @@ void main() { /// /// The test case must have already called [prepare] to initialize the state. Future prepareMessages({ - required bool foundOldest, + bool? foundOldest, + bool? foundNewest, + int? anchorMessageId, required List messages, }) async { - connection.prepare(json: - newestResult(foundOldest: foundOldest, messages: messages).toJson()); + final result = eg.getMessagesResult( + anchor: model.anchor == AnchorCode.firstUnread + ? NumericAnchor(anchorMessageId!) : model.anchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + messages: messages); + connection.prepare(json: result.toJson()); await model.fetchInitial(); checkNotifiedOnce(); } + Future prepareOutboxMessages({ + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content'); + } + } + + Future prepareOutboxMessagesTo(List destinations) async { + for (final destination in destinations) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage(destination: destination, content: 'content'); + } + } + void checkLastRequest({ required ApiNarrow narrow, required String anchor, bool? includeAnchor, required int numBefore, required int numAfter, + required bool allowEmptyTopicName, }) { check(connection.lastRequest).isA() ..method.equals('GET') @@ -92,9 +155,18 @@ void main() { if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), 'num_before': numBefore.toString(), 'num_after': numAfter.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } + void checkHasMessageIds(Iterable messageIds) { + check(model.messages.map((m) => m.id)).deepEquals(messageIds); + } + + void checkHasMessages(Iterable messages) { + checkHasMessageIds(messages.map((e) => e.id)); + } + group('fetchInitial', () { final someChannel = eg.stream(); const someTopic = 'some topic'; @@ -120,12 +192,14 @@ void main() { checkNotifiedOnce(); check(model) ..messages.length.equals(kMessageListFetchBatchSize) - ..haveOldest.isFalse(); + ..haveOldest.isFalse() + ..haveNewest.isTrue(); checkLastRequest( narrow: narrow.apiEncode(), anchor: 'newest', numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, ); } @@ -149,7 +223,22 @@ void main() { checkNotifiedOnce(); check(model) ..messages.length.equals(30) - ..haveOldest.isTrue(); + ..haveOldest.isTrue() + ..haveNewest.isTrue(); + }); + + test('early in history', () async { + await prepare(anchor: NumericAnchor(1000)); + connection.prepare(json: nearResult( + anchor: 1000, foundOldest: true, foundNewest: false, + messages: List.generate(111, (i) => eg.streamMessage(id: 990 + i)), + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(111) + ..haveOldest.isTrue() + ..haveNewest.isFalse(); }); test('no messages found', () async { @@ -163,9 +252,129 @@ void main() { check(model) ..fetched.isTrue() ..messages.isEmpty() - ..haveOldest.isTrue(); + ..haveOldest.isTrue() + ..haveNewest.isTrue(); + }); + + group('sends proper anchor', () { + Future checkFetchWithAnchor(Anchor anchor) async { + await prepare(anchor: anchor); + // This prepared response isn't entirely realistic, depending on the anchor. + // That's OK; these particular tests don't use the details of the response. + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(connection.lastRequest).isA() + .url.queryParameters['anchor'] + .equals(anchor.toJson()); + } + + test('oldest', () => checkFetchWithAnchor(AnchorCode.oldest)); + test('firstUnread', () => checkFetchWithAnchor(AnchorCode.firstUnread)); + test('newest', () => checkFetchWithAnchor(AnchorCode.newest)); + test('numeric', () => checkFetchWithAnchor(NumericAnchor(12345))); }); + test('no messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + connection.prepare( + json: newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); + + test('some messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + connection.prepare(json: newestResult(foundOldest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); + + test('outbox messages not added until haveNewest', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), + anchor: AnchorCode.firstUnread, + stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model)..fetched.isFalse()..outboxMessages.isEmpty(); + + final message = eg.streamMessage(stream: stream, topic: 'topic'); + connection.prepare(json: nearResult( + anchor: message.id, + foundOldest: true, + foundNewest: false, + messages: [message]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model)..fetched.isTrue()..haveNewest.isFalse()..outboxMessages.isEmpty(); + + connection.prepare(json: newerResult(anchor: message.id, foundNewest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + await fetchFuture; + checkNotifiedOnce(); + check(model)..haveNewest.isTrue()..outboxMessages.length.equals(1); + })); + + test('ignore [OutboxMessage]s outside narrow or with `hidden: true`', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final otherStream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('topic')), + StreamDestination(stream.streamId, eg.t('muted')), + StreamDestination(otherStream.streamId, eg.t('topic')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('topic'))]); + assert(store.outboxMessages.values.last.hidden); + + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t('topic')); + })); + // TODO(#824): move this test test('recent senders track all the messages', () async { const narrow = CombinedFeedNarrow(); @@ -212,8 +421,8 @@ void main() { }); }); - group('fetchOlder', () { - test('smoke', () async { + group('fetching more', () { + test('fetchOlder smoke', () async { const narrow = CombinedFeedNarrow(); await prepare(narrow: narrow); await prepareMessages(foundOldest: false, @@ -225,12 +434,12 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); await fetchFuture; checkNotifiedOnce(); check(model) - ..fetchingOlder.isFalse() + ..busyFetchingMore.isFalse() ..messages.length.equals(200); checkLastRequest( narrow: narrow.apiEncode(), @@ -238,45 +447,106 @@ void main() { includeAnchor: false, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); }); - test('nop when already fetching', () async { + test('fetchNewer smoke', () async { const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, + await prepare(narrow: narrow, anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + connection.prepare(json: newerResult( + anchor: 1099, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(200); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: '1099', + includeAnchor: false, + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, + ); + }); + + test('nop when already fetching older', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), + anchor: 900, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 800 + i)), ).toJson()); final fetchFuture = model.fetchOlder(); checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); // Don't prepare another response. final fetchFuture2 = model.fetchOlder(); checkNotNotified(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); await fetchFuture; await fetchFuture2; + await fetchFuture3; // We must not have made another request, because we didn't // prepare another response and didn't get an exception. checkNotifiedOnce(); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); }); - test('nop when already haveOldest true', () async { - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage())); + test('nop when already fetching newer', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1101 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + // Don't prepare another response. + final fetchFuture2 = model.fetchOlder(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); + + await fetchFuture; + await fetchFuture2; + await fetchFuture3; + // We must not have made another request, because we didn't + // prepare another response and didn't get an exception. + checkNotifiedOnce(); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); + }); + + test('fetchOlder nop when already haveOldest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); check(model) ..haveOldest.isTrue() - ..messages.length.equals(30); + ..messages.length.equals(151); await model.fetchOlder(); // We must not have made a request, because we didn't @@ -284,45 +554,73 @@ void main() { checkNotNotified(); check(model) ..haveOldest.isTrue() - ..messages.length.equals(30); + ..messages.length.equals(151); + }); + + test('fetchNewer nop when already haveNewest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: true, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); + + await model.fetchNewer(); + // We must not have made a request, because we didn't + // prepare a response and didn't get an exception. + checkNotNotified(); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); }); test('nop during backoff', () => awaitFakeAsync((async) async { final olderMessages = List.generate(5, (i) => eg.streamMessage()); final initialMessages = List.generate(5, (i) => eg.streamMessage()); - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: false, messages: initialMessages); + final newerMessages = List.generate(5, (i) => eg.streamMessage()); + await prepare(anchor: NumericAnchor(initialMessages[2].id)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: initialMessages); check(connection.takeRequests()).single; connection.prepare(apiException: eg.apiBadRequest()); check(async.pendingTimers).isEmpty(); await check(model.fetchOlder()).throws(); checkNotified(count: 2); - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(connection.takeRequests()).single; await model.fetchOlder(); checkNotNotified(); - check(model).fetchOlderCoolingDown.isTrue(); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isTrue(); + check(connection.lastRequest).isNull(); + + await model.fetchNewer(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); check(connection.lastRequest).isNull(); // Wait long enough that a first backoff is sure to finish. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); check(connection.lastRequest).isNull(); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, messages: olderMessages).toJson()); + connection.prepare(json: olderResult(anchor: initialMessages.first.id, + foundOldest: false, messages: olderMessages).toJson()); await model.fetchOlder(); checkNotified(count: 2); check(connection.takeRequests()).single; + + connection.prepare(json: newerResult(anchor: initialMessages.last.id, + foundNewest: false, messages: newerMessages).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(connection.takeRequests()).single; })); - test('handles servers not understanding includeAnchor', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); + test('fetchOlder handles servers not understanding includeAnchor', () async { + await prepare(); await prepareMessages(foundOldest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); @@ -334,14 +632,30 @@ void main() { await model.fetchOlder(); checkNotified(count: 2); check(model) - ..fetchingOlder.isFalse() + ..busyFetchingMore.isFalse() ..messages.length.equals(200); }); + test('fetchNewer handles servers not understanding includeAnchor', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: List.generate(101, (i) => eg.streamMessage(id: 1000 + i))); + + // The old behavior is to include the anchor message regardless of includeAnchor. + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, foundAnchor: true, + messages: List.generate(101, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(201); + }); + // TODO(#824): move this test - test('recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); + test('fetchOlder recent senders track all the messages', () async { + await prepare(); final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); await prepareMessages(foundOldest: false, messages: initialMessages); @@ -358,39 +672,270 @@ void main() { recent_senders_test.checkMatchesMessages(store.recentSenders, [...initialMessages, ...oldMessages]); }); + + // TODO(#824): move this test + test('TODO fetchNewer recent senders track all the messages', () async { + await prepare(anchor: NumericAnchor(100)); + final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: initialMessages); + + final newMessages = List.generate(10, (i) => eg.streamMessage(id: 110 + i)) + // Not subscribed to the stream with id 10. + ..add(eg.streamMessage(id: 120, stream: eg.stream(streamId: 10))); + connection.prepare(json: newerResult( + anchor: 100, foundNewest: false, + messages: newMessages, + ).toJson()); + await model.fetchNewer(); + + check(model).messages.length.equals(20); + recent_senders_test.checkMatchesMessages(store.recentSenders, + [...initialMessages, ...newMessages]); + }); }); - test('MessageEvent', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); + // TODO(#1569): test jumpToEnd - check(model).messages.length.equals(30); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotifiedOnce(); - check(model).messages.length.equals(31); + group('MessageEvent', () { + test('in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + }); + + test('not in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + check(model).messages.length.equals(30); + final otherStream = eg.stream(); + await store.addMessage(eg.streamMessage(stream: otherStream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); + + test('while in mid-history', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId), + anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); + + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); + + test('before fetch', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).fetched.isFalse(); + }); + + test('when there are outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream))); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(5); + })); + + test('from another client (localMessageId present but unrecognized)', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: 1234)); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); + + test('for an OutboxMessage in the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5) + ..outboxMessages.any((message) => + message.localMessageId.equals(localMessageId)); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream), + localMessageId: localMessageId)); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(4) + ..outboxMessages.every((message) => + message.localMessageId.not((m) => m.equals(localMessageId))); + })); + + test('for an OutboxMessage outside the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other'); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'other'), + localMessageId: localMessageId)); + checkNotNotified(); + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); }); - test('MessageEvent, not in narrow', () async { + group('addOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - final otherStream = eg.stream(); - await store.addMessage(eg.streamMessage(stream: otherStream)); - checkNotNotified(); - check(model).messages.length.equals(30); + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + await prepareOutboxMessages(count: 5, stream: stream); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model).outboxMessages.length.equals(5); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + })); + + test('before fetch', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareOutboxMessages(count: 5, stream: stream); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + })); }); - test('MessageEvent, before fetch', () async { + group('removeOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotNotified(); - check(model).fetched.isFalse(); + + Future prepareFailedOutboxMessages(FakeAsync async, { + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(httpException: SocketException('failed')); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content')).throws(); + } + } + + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream); + check(model).outboxMessages.length.equals(5); + checkNotified(count: 5); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + checkNotifiedOnce(); + check(model).outboxMessages.length.equals(4); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + })); + + test('removed outbox message is the only message in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareFailedOutboxMessages(async, + count: 1, stream: stream); + check(model).outboxMessages.single; + checkNotified(count: 1); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); }); group('UserTopicEvent', () { @@ -414,11 +959,7 @@ void main() { await setVisibility(policy); } - void checkHasMessageIds(Iterable messageIds) { - check(model.messages.map((m) => m.id)).deepEquals(messageIds); - } - - test('mute a visible topic', () async { + test('mute a visible topic', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); final otherStream = eg.stream(); @@ -432,10 +973,49 @@ void main() { ]); checkHasMessageIds([1, 2, 3, 4]); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('elsewhere')), + DmDestination(userIds: [eg.selfUser.userId]), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t(topic)), + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotifiedOnce(); checkHasMessageIds([1, 3, 4]); - }); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + })); + + test('mute a visible topic containing only outbox messages', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMutes(); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t(topic)), + ]); + async.elapse(kLocalEchoDebounceDuration); + check(model).outboxMessages.length.equals(2); + checkNotified(count: 2); + + await setVisibility(UserTopicVisibilityPolicy.muted); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); test('in CombinedFeedNarrow, use combined-feed visibility', () async { // Compare the parallel ChannelNarrow test below. @@ -510,7 +1090,7 @@ void main() { checkHasMessageIds([1]); }); - test('no affected messages -> no notification', () async { + test('no affected messages -> no notification', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); await prepareMessages(foundOldest: true, messages: [ @@ -518,10 +1098,17 @@ void main() { ]); checkHasMessageIds([1]); + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('bar'))]); + async.elapse(kLocalEchoDebounceDuration); + final outboxMessage = model.outboxMessages.single; + checkNotifiedOnce(); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotNotified(); checkHasMessageIds([1]); - }); + check(model).outboxMessages.single.equals(outboxMessage); + })); test('unmute a topic -> refetch from scratch', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); @@ -531,7 +1118,14 @@ void main() { eg.streamMessage(id: 2, stream: stream, topic: topic), ]; await prepareMessages(foundOldest: true, messages: messages); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); checkHasMessageIds([1]); + check(model).outboxMessages.isEmpty(); connection.prepare( json: newestResult(foundOldest: true, messages: messages).toJson()); @@ -539,10 +1133,14 @@ void main() { checkNotifiedOnce(); check(model).fetched.isFalse(); checkHasMessageIds([]); + check(model).outboxMessages.isEmpty(); async.elapse(Duration.zero); checkNotifiedOnce(); checkHasMessageIds([1, 2]); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t(topic)); })); test('unmute a topic before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { @@ -618,11 +1216,11 @@ void main() { check(model).messages.length.equals(30); await store.handleEvent(eg.deleteMessageEvent(messagesToDelete)); checkNotifiedOnce(); - check(model.messages.map((message) => message.id)).deepEquals([ + checkHasMessages([ ...messages.sublist(0, 2), ...messages.sublist(5, 10), ...messages.sublist(15), - ].map((message) => message.id)); + ]); }); }); @@ -688,6 +1286,38 @@ void main() { }); }); + group('notifyListenersIfOutboxMessagePresent', () { + final stream = eg.stream(); + + test('message present', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow(), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotifiedOnce(); + })); + + test('message not present', () => awaitFakeAsync((async) async { + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'some topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, + stream: stream, topic: 'other topic'); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotNotified(); + })); + }); + group('messageContentChanged', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); @@ -725,10 +1355,6 @@ void main() { final stream = eg.stream(); final otherStream = eg.stream(); - void checkHasMessages(Iterable messages) { - check(model.messages.map((e) => e.id)).deepEquals(messages.map((e) => e.id)); - } - Future prepareNarrow(Narrow narrow, List? messages) async { await prepare(narrow: narrow); for (final streamToAdd in [stream, otherStream]) { @@ -821,6 +1447,26 @@ void main() { checkNotifiedOnce(); }); + test('channel -> new channel (with outbox messages): remove moved messages; outbox messages unaffected', () => awaitFakeAsync((async) async { + final narrow = ChannelNarrow(stream.streamId); + await prepareNarrow(narrow, initialMessages + movedMessages); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final outboxMessagesCopy = model.outboxMessages.toList(); + + await store.handleEvent(eg.updateMessageEventMoveFrom( + origMessages: movedMessages, + newTopicStr: 'new', + newStreamId: otherStream.streamId, + )); + checkHasMessages(initialMessages); + check(model).outboxMessages.deepEquals(outboxMessagesCopy); + checkNotifiedOnce(); + })); + test('unrelated channel -> new channel: unaffected', () async { final thirdStream = eg.stream(); await prepareNarrow(narrow, initialMessages); @@ -976,7 +1622,13 @@ void main() { newStreamId: otherStream.streamId, propagateMode: propagateMode, )); - checkNotifiedOnce(); + switch (propagateMode) { + case PropagateMode.changeOne: + checkNotifiedOnce(); + case PropagateMode.changeLater: + case PropagateMode.changeAll: + checkNotified(count: 2); + } async.elapse(const Duration(seconds: 1)); }); @@ -1042,7 +1694,7 @@ void main() { messages: olderMessages, ).toJson()); final fetchFuture = model.fetchOlder(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -1055,7 +1707,7 @@ void main() { origStreamId: otherStream.streamId, newMessages: movedMessages, )); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkHasMessages([]); checkNotifiedOnce(); @@ -1078,7 +1730,7 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1091,7 +1743,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1112,7 +1764,7 @@ void main() { BackoffMachine.debugDuration = const Duration(seconds: 1); await check(model.fetchOlder()).throws(); final backoffTimerA = async.pendingTimers.single; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(model).fetched.isTrue(); checkHasMessages(initialMessages); checkNotified(count: 2); @@ -1130,36 +1782,36 @@ void main() { check(model).fetched.isFalse(); checkHasMessages([]); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); async.elapse(Duration.zero); check(model).fetched.isTrue(); checkHasMessages(initialMessages + movedMessages); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 2); await check(model.fetchOlder()).throws(); final backoffTimerB = async.pendingTimers.last; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isTrue(); check(backoffTimerB.isActive).isTrue(); checkNotified(count: 2); - // When `backoffTimerA` ends, `fetchOlderCoolingDown` remains `true` + // When `backoffTimerA` ends, `busyFetchingMore` remains `true` // because the backoff was from a previous generation. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isTrue(); checkNotNotified(); - // When `backoffTimerB` ends, `fetchOlderCoolingDown` gets reset. + // When `backoffTimerB` ends, `busyFetchingMore` gets reset. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isFalse(); checkNotifiedOnce(); @@ -1241,7 +1893,7 @@ void main() { ).toJson()); final fetchFuture1 = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1254,7 +1906,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1267,19 +1919,19 @@ void main() { ).toJson()); final fetchFuture2 = model.fetchOlder(); checkHasMessages(initialMessages + movedMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); await fetchFuture1; checkHasMessages(initialMessages + movedMessages); // The older fetchOlder call should not override fetchingOlder set by // the new fetchOlder call, nor should it notify the listeners. - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotNotified(); await fetchFuture2; checkHasMessages(olderMessages + initialMessages + movedMessages); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); })); }); @@ -1295,12 +1947,14 @@ void main() { int notifiedCount1 = 0; final model1 = MessageListView.init(store: store, - narrow: ChannelNarrow(stream.streamId)) + narrow: ChannelNarrow(stream.streamId), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount1++); int notifiedCount2 = 0; final model2 = MessageListView.init(store: store, - narrow: eg.topicNarrow(stream.streamId, 'hello')) + narrow: eg.topicNarrow(stream.streamId, 'hello'), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount2++); for (final m in [model1, model2]) { @@ -1340,7 +1994,8 @@ void main() { await store.handleEvent(mkEvent(message)); // init msglist *after* event was handled - model = MessageListView.init(store: store, narrow: const CombinedFeedNarrow()); + model = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), anchor: AnchorCode.newest); checkInvariants(model); connection.prepare(json: @@ -1432,8 +2087,7 @@ void main() { eg.dmMessage( id: 205, from: eg.otherUser, to: [eg.selfUser]), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 203, 205])); + checkHasMessageIds(expected..addAll([201, 203, 205])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1446,34 +2100,33 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 103, 105])); + checkHasMessageIds(expected..insertAll(0, [101, 103, 105])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream1, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); await store.addMessage( eg.streamMessage(id: 302, stream: stream1, topic: 'B')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); await store.addMessage( eg.streamMessage(id: 303, stream: stream2, topic: 'C')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(303)); + checkHasMessageIds(expected..add(303)); await store.addMessage( eg.streamMessage(id: 304, stream: stream2, topic: 'D')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); await store.addMessage( eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser])); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(305)); + checkHasMessageIds(expected..add(305)); }); test('in ChannelNarrow', () async { @@ -1491,8 +2144,7 @@ void main() { eg.streamMessage(id: 203, stream: stream, topic: 'C'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1503,26 +2155,58 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); await store.addMessage( eg.streamMessage(id: 302, stream: stream, topic: 'B')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(302)); + checkHasMessageIds(expected..add(302)); await store.addMessage( eg.streamMessage(id: 303, stream: stream, topic: 'C')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); }); + test('handle outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareMessages(foundOldest: true, messages: []); + + // Check filtering on sent messages… + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('not muted')), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + + final messages = [eg.streamMessage(stream: stream)]; + connection.prepare(json: newestResult( + foundOldest: true, messages: messages).toJson()); + // Check filtering on fetchInitial… + await store.handleEvent(eg.updateMessageEventMoveTo( + newMessages: messages, + origStreamId: eg.stream().streamId)); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + async.elapse(Duration.zero); + check(model).fetched.isTrue(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + })); + test('in TopicNarrow', () async { final stream = eg.stream(); await prepare(narrow: eg.topicNarrow(stream.streamId, 'A')); @@ -1535,8 +2219,7 @@ void main() { eg.streamMessage(id: 201, stream: stream, topic: 'A'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201])); + checkHasMessageIds(expected..addAll([201])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1545,14 +2228,13 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101])); + checkHasMessageIds(expected..insertAll(0, [101])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); }); test('in MentionsNarrow', () async { @@ -1575,23 +2257,21 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202, 203])); + checkHasMessageIds(expected..addAll([201, 202, 203])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102, 103])); + checkHasMessageIds(expected..insertAll(0, [101, 102, 103])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 3; i += 1) { await store.addMessage(messages[i]); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); + checkHasMessageIds(expected..add(301 + i)); } }); @@ -1613,24 +2293,259 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 2; i += 1) { await store.addMessage(messages[i]); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); + checkHasMessageIds(expected..add(301 + i)); + } + }); + }); + + group('middleMessage maintained', () { + // In [checkInvariants] we verify that messages don't move from the + // top to the bottom slice or vice versa. + // Most of these test cases rely on that for all the checks they need. + + test('on fetchInitial empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMessages(foundOldest: true, messages: []); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial empty due to muting', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream, isMuted: true)); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(stream: stream), + ]); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial, anchor past end', () async { + await prepare(narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest); + final stream1 = eg.stream(); + final stream2 = eg.stream(); + await store.addStreams([stream1, stream2]); + await store.addSubscription(eg.subscription(stream1)); + await store.addSubscription(eg.subscription(stream2, isMuted: true)); + final messages = [ + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + // The anchor message is the last visible message… + check(model) + ..messages.length.equals(5) + ..middleMessage.equals(model.messages.length - 1) + // … even though that's not the last message that was in the response. + ..messages[model.middleMessage].id + .equals(messages[messages.length - 2].id); + }); + + test('on fetchInitial, anchor in middle', () async { + final s1 = eg.stream(); + final s2 = eg.stream(); + final messages = [ + eg.streamMessage(id: 1, stream: s1), eg.streamMessage(id: 2, stream: s2), + eg.streamMessage(id: 3, stream: s1), eg.streamMessage(id: 4, stream: s2), + eg.streamMessage(id: 5, stream: s1), eg.streamMessage(id: 6, stream: s2), + eg.streamMessage(id: 7, stream: s1), eg.streamMessage(id: 8, stream: s2), + ]; + final anchorId = 4; + + await prepare(narrow: const CombinedFeedNarrow(), + anchor: NumericAnchor(anchorId)); + await store.addStreams([s1, s2]); + await store.addSubscription(eg.subscription(s1)); + await store.addSubscription(eg.subscription(s2, isMuted: true)); + await prepareMessages(foundOldest: true, foundNewest: true, + messages: messages); + // The anchor message is the first visible message with ID at least anchorId… + check(model) + ..messages[model.middleMessage - 1].id.isLessThan(anchorId) + ..messages[model.middleMessage].id.isGreaterOrEqual(anchorId); + // … even though a non-visible message actually had anchorId itself. + check(messages[3].id) + ..equals(anchorId) + ..isLessThan(model.messages[model.middleMessage].id); + }); + + /// Like [prepareMessages], but arrange for the given top and bottom slices. + Future prepareMessageSplit(List top, List bottom, { + bool foundOldest = true, + }) async { + assert(bottom.isNotEmpty); // could handle this too if necessary + await prepareMessages(foundOldest: foundOldest, messages: [ + ...top, + bottom.first, + ]); + if (bottom.length > 1) { + await store.addMessages(bottom.skip(1)); + checkNotifiedOnce(); } + check(model) + ..messages.length.equals(top.length + bottom.length) + ..middleMessage.equals(top.length); + } + + test('on fetchOlder', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(id: 100, stream: stream)], + [eg.streamMessage(id: 101, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + }); + + test('on fetchOlder, from top empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [], [eg.streamMessage(id: 100, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + // The messages from fetchOlder should go in the top sliver, always. + check(model).middleMessage.equals(5); + }); + + test('on MessageEvent', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(stream: stream)], + [eg.streamMessage(stream: stream)]); + + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + }); + + test('on messages muted, including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'bar'), + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, not including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'third'), + ]); + + await store.handleEvent(eg.deleteMessageEvent([ + model.messages.last as StreamMessage])); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages deleted', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3))); + checkNotifiedOnce(); + }); + + test('on messages deleted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3))); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2))); + checkNotifiedOnce(); }); }); @@ -1670,7 +2585,55 @@ void main() { }); }); - test('recipient headers are maintained consistently', () async { + group('findItemWithMessageId', () { + test('has MessageListDateSeparatorItem with null message ID', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.daysAgo(1))); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListDateSeparatorItem] for + // the outbox messages is right in the middle. + await prepareOutboxMessages(count: 2, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 2); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA().message.id.isNull(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + + test('has MessageListOutboxMessageItem', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.now())); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListOutboxMessageItem] + // is right in the middle. + await prepareOutboxMessages(count: 3, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + }); + + test('recipient headers are maintained consistently (Combined feed)', () => awaitFakeAsync((async) async { // TODO test date separators are maintained consistently too // This tests the code that maintains the invariant that recipient headers // are present just where they're required. @@ -1683,7 +2646,7 @@ void main() { // just needs messages that have the same recipient, and that don't, and // doesn't need to exercise the different reasons that messages don't. - const timestamp = 1693602618; + final timestamp = eg.utcTimestamp(clock.now()); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); Message streamMessage(int id) => eg.streamMessage(id: id, stream: stream, topic: 'foo', timestamp: timestamp); @@ -1691,7 +2654,7 @@ void main() { eg.dmMessage(id: id, from: eg.selfUser, to: [], timestamp: timestamp); // First, test fetchInitial, where some headers are needed and others not. - await prepare(); + await prepare(narrow: CombinedFeedNarrow()); connection.prepare(json: newestResult( foundOldest: false, messages: [streamMessage(10), streamMessage(11), dmMessage(12)], @@ -1742,6 +2705,20 @@ void main() { model.reassemble(); checkNotifiedOnce(); + // Then test outbox message, where a new header is needed… + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + + // … and where it's not. + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + // Have a new fetchOlder reach the oldest, so that a history-start marker appears… connection.prepare(json: olderResult( anchor: model.messages[0].id, @@ -1754,17 +2731,80 @@ void main() { // … and then test reassemble again. model.reassemble(); checkNotifiedOnce(); + + final outboxMessageIds = store.outboxMessages.keys.toList(); + // Then test removing the first outbox message… + await store.handleEvent(eg.messageEvent( + dmMessage(15), localMessageId: outboxMessageIds.first)); + checkNotifiedOnce(); + + // … and handling a new non-outbox message… + await store.handleEvent(eg.messageEvent(streamMessage(16))); + checkNotifiedOnce(); + + // … and removing the second outbox message. + await store.handleEvent(eg.messageEvent( + dmMessage(17), localMessageId: outboxMessageIds.last)); + checkNotifiedOnce(); + })); + + group('one message per block?', () { + final channelId = 1; + final topic = 'some topic'; + void doTest({required Narrow narrow, required bool expected}) { + test('$narrow: ${expected ? 'yes' : 'no'}', () => awaitFakeAsync((async) async { + final sender = eg.user(); + final channel = eg.stream(streamId: channelId); + final message1 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + final message2 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + + await prepare( + narrow: narrow, + stream: channel, + ); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [message1, message2], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + + check(model).items.deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + if (expected) (it) => it.isA(), + (it) => it.isA(), + ]); + })); + } + + doTest(narrow: CombinedFeedNarrow(), expected: false); + doTest(narrow: ChannelNarrow(channelId), expected: false); + doTest(narrow: TopicNarrow(channelId, eg.t(topic)), expected: false); + doTest(narrow: StarredMessagesNarrow(), expected: true); + doTest(narrow: MentionsNarrow(), expected: true); }); - test('showSender is maintained correctly', () async { + test('showSender is maintained correctly', () => awaitFakeAsync((async) async { // TODO(#150): This will get more complicated with message moves. // Until then, we always compute this sequentially from oldest to newest. // So we just need to exercise the different cases of the logic for // whether the sender should be shown, but the difference between // fetchInitial and handleMessageEvent etc. doesn't matter. - const t1 = 1693602618; - const t2 = t1 + 86400; + final now = clock.now(); + final t1 = eg.utcTimestamp(now.subtract(Duration(days: 1))); + final t2 = eg.utcTimestamp(now); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); Message streamMessage(int id, int timestamp, User sender) => eg.streamMessage(id: id, sender: sender, @@ -1772,6 +2812,8 @@ void main() { Message dmMessage(int id, int timestamp, User sender) => eg.dmMessage(id: id, from: sender, timestamp: timestamp, to: [sender.userId == eg.selfUser.userId ? eg.otherUser : eg.selfUser]); + DmDestination dmDestination(List users) => + DmDestination(userIds: users.map((user) => user.userId).toList()); await prepare(); await prepareMessages(foundOldest: true, messages: [ @@ -1781,11 +2823,17 @@ void main() { dmMessage(4, t1, eg.otherUser), // same sender, but new recipient dmMessage(5, t2, eg.otherUser), // same sender/recipient, but new day ]); + await prepareOutboxMessagesTo([ + dmDestination([eg.selfUser, eg.otherUser]), // same day, but new sender + dmDestination([eg.selfUser, eg.otherUser]), // hide sender + ]); + assert( + store.outboxMessages.values.every((message) => message.timestamp == t2)); + async.elapse(kLocalEchoDebounceDuration); // We check showSender has the right values in [checkInvariants], // but to make this test explicit: check(model.items).deepEquals()>[ - (it) => it.isA(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), (it) => it.isA().showSender.isFalse(), @@ -1794,8 +2842,10 @@ void main() { (it) => it.isA().showSender.isTrue(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isFalse(), ]); - }); + })); group('haveSameRecipient', () { test('stream messages vs DMs, no match', () { @@ -1866,6 +2916,16 @@ void main() { doTest('same letters, different diacritics', 'ma', 'mǎ', false); doTest('having different CJK characters', '嗎', '馬', false); }); + + test('outbox messages', () { + final stream = eg.stream(); + final streamMessage1 = eg.streamOutboxMessage(stream: stream, topic: 'foo'); + final streamMessage2 = eg.streamOutboxMessage(stream: stream, topic: 'bar'); + final dmMessage = eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]); + check(haveSameRecipient(streamMessage1, streamMessage1)).isTrue(); + check(haveSameRecipient(streamMessage1, streamMessage2)).isFalse(); + check(haveSameRecipient(streamMessage1, dmMessage)).isFalse(); + }); }); test('messagesSameDay', () { @@ -1901,6 +2961,14 @@ void main() { eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time0)), + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); } } } @@ -1908,43 +2976,82 @@ void main() { }); } +MessageListView? _lastModel; +List? _lastMessages; +int? _lastMiddleMessage; + void checkInvariants(MessageListView model) { if (!model.fetched) { check(model) ..messages.isEmpty() + ..outboxMessages.isEmpty() ..haveOldest.isFalse() - ..fetchingOlder.isFalse() - ..fetchOlderCoolingDown.isFalse(); - } - if (model.haveOldest) { - check(model).fetchingOlder.isFalse(); - check(model).fetchOlderCoolingDown.isFalse(); + ..haveNewest.isFalse() + ..busyFetchingMore.isFalse(); } - if (model.fetchingOlder) { - check(model).fetchOlderCoolingDown.isFalse(); + if (model.haveOldest && model.haveNewest) { + check(model).busyFetchingMore.isFalse(); } for (final message in model.messages) { check(model.store.messages)[message.id].isNotNull().identicalTo(message); - check(model.narrow.containsMessage(message)).isTrue(); + } + if (model.outboxMessages.isNotEmpty) { + check(model.haveNewest).isTrue(); + } + for (final message in model.outboxMessages) { + check(message).hidden.isFalse(); + check(model.store.outboxMessages)[message.localMessageId].isNotNull().identicalTo(message); + } + + final allMessages = [...model.messages, ...model.outboxMessages]; - if (message is! StreamMessage) continue; + for (final message in allMessages) { + check(model.narrow.containsMessage(message)).anyOf(>[ + (it) => it.isNull(), + (it) => it.isNotNull().isTrue(), + ]); + + 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(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): } } check(isSortedWithoutDuplicates(model.messages.map((m) => m.id).toList())) .isTrue(); + check(isSortedWithoutDuplicates(model.outboxMessages.map((m) => m.localMessageId).toList())) + .isTrue(); + + check(model).middleMessage + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.messages.length); + + if (identical(model, _lastModel) + && model.generation == _lastModel!.generation) { + // All messages that were present, and still are, should be on the same side + // of `middleMessage` (still top or bottom slice respectively) as they were. + _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage), + ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length), + because: 'messages moved from bottom slice to top slice'); + _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!), + ListSlice(model.messages, model.middleMessage, model.messages.length), + because: 'messages moved from top slice to bottom slice'); + } + _lastModel = model; + _lastMessages = model.messages.toList(); + _lastMiddleMessage = model.middleMessage; check(model).contents.length.equals(model.messages.length); for (int i = 0; i < model.contents.length; i++) { @@ -1958,39 +3065,66 @@ void checkInvariants(MessageListView model) { } int i = 0; - if (model.haveOldest) { - check(model.items[i++]).isA(); - } - if (model.fetchingOlder || model.fetchOlderCoolingDown) { - check(model.items[i++]).isA(); - } - 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])) { + || model.oneMessagePerBlock + || !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 { + check(model.items[i]).isA() + .message.identicalTo(model.outboxMessages[j-model.messages.length]); + } + 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() + || MessageListOutboxMessageItem() || MessageListDateSeparatorItem() => false, - MessageListRecipientHeaderItem() - || MessageListHistoryStartItem() - || MessageListLoadingItem() => true, + MessageListRecipientHeaderItem() => true, }); } check(model.items).length.equals(i); + + check(model).middleItem + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.items.length); + if (model.middleMessage == model.messages.length) { + if (model.outboxMessages.isEmpty) { + // the bottom slice of `model.messages` is empty + check(model).middleItem.equals(model.items.length); + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.outboxMessages.first); + } + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.messages[model.middleMessage]); + } +} + +void _checkNoIntersection(List xs, List ys, {String? because}) { + // Both lists are sorted by ID. As an optimization, bet on all or nearly all + // of the first list having smaller IDs than all or nearly all of the other. + if (xs.isEmpty || ys.isEmpty) return; + if (xs.last.id < ys.first.id) return; + final yCandidates = Set.of(ys.takeWhile((m) => m.id <= xs.last.id)); + final intersection = xs.reversed.takeWhile((m) => ys.first.id <= m.id) + .where(yCandidates.contains); + check(intersection, because: because).isEmpty(); } extension MessageListRecipientHeaderItemChecks on Subject { @@ -2001,21 +3135,28 @@ extension MessageListDateSeparatorItemChecks on Subject get message => has((x) => x.message, 'message'); } -extension MessageListMessageItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); +extension MessageListMessageBaseItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); Subject get content => has((x) => x.content, 'content'); Subject get showSender => has((x) => x.showSender, 'showSender'); Subject get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock'); } +extension MessageListMessageItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); +} + extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get outboxMessages => has((x) => x.outboxMessages, 'outboxMessages'); + Subject get middleMessage => has((x) => x.middleMessage, 'middleMessage'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); + Subject get middleItem => has((x) => x.middleItem, 'middleItem'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); - Subject get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder'); - Subject get fetchOlderCoolingDown => has((x) => x.fetchOlderCoolingDown, 'fetchOlderCoolingDown'); + Subject get haveNewest => has((x) => x.haveNewest, 'haveNewest'); + Subject get busyFetchingMore => has((x) => x.busyFetchingMore, 'busyFetchingMore'); } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 25e89b0a54..762cc41452 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,10 +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'; @@ -13,12 +20,18 @@ import '../api/fake_api.dart'; 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; @@ -37,33 +50,40 @@ 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({ + 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; notifiedCount = 0; - messageList = MessageListView.init(store: store, narrow: narrow) + messageList = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest) ..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]. /// /// The test case must have already called [prepare] to initialize the state. - /// - /// This does not support submessages. Use [prepareMessageWithSubmessages] - /// instead if needed. Future prepareMessages( List messages, { bool foundOldest = false, }) async { - assert(messages.every((message) => message.poll == null)); connection.prepare(json: eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); await messageList.fetchInitial(); @@ -75,6 +95,383 @@ 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); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: StreamDestination( + stream.streamId, eg.t('foo'))); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('hidden -> waiting and never transition to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + test('waiting -> waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotifiedOnce(); + + 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); + checkNotified(count: 2); + + // Wait till the [sendMessage] request succeeds. + await future; + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + group('… -> failed', () { + test('hidden -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + test('waiting -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + })); + + test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotified(count: 2); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + })); + }); + + group('… -> (delete)', () { + test('hidden -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + 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); + checkNotNotified(); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // 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)); + checkNotNotified(); + })); + + test('waiting -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + 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); + checkNotifiedOnce(); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // 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(); + checkNotNotified(); + })); + + 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); + checkNotified(count: 2); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // 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(); + checkNotNotified(); + })); + + 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); + checkNotified(count: 2); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('failed -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + }); + + 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(); + checkNotifiedOnce(); + } + + final localMessageIds = store.outboxMessages.keys.toList(); + store.takeOutboxMessage(localMessageIds.removeAt(5)); + check(store.outboxMessages).keys.deepEquals(localMessageIds); + checkNotifiedOnce(); + }); + group('reconcileMessages', () { test('from empty', () async { await prepare(); @@ -123,6 +520,295 @@ void main() { }); }); + group('edit-message methods', () { + late StreamMessage message; + Future prepareEditMessage() async { + await prepare(); + message = eg.streamMessage(); + await prepareMessages([message]); + check(connection.takeRequests()).length.equals(1); // message-list fetchInitial + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + test('smoke', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + + async.elapse(Duration(milliseconds: 500)); + // Request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('concurrent edits on different messages', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + final otherMessage = eg.streamMessage(); + await store.addMessage(otherMessage); + checkNotifiedOnce(); + + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-first request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: otherMessage.id, + originalRawContent: 'other message old content', newContent: 'other message new content'); + checkRequest(otherMessage.id, + prevContent: 'other message old content', + content: 'other message new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // First request has succeeded; event hasn't arrived + // Mid-second request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // First event arrives + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Second request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // Second event arrives + await store.handleEvent(eg.updateMessageEditEvent(otherMessage)); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + })); + + test('request fails; take failed edit', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('takeFailedMessageEdit throws StateError when nothing to take', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + check(() => store.takeFailedMessageEdit(message.id)).throws(); + })); + + test('editMessage throws StateError if editMessage already in progress for same message', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + async.elapse(Duration(milliseconds: 500)); + check(connection.takeRequests()).length.equals(1); + checkNotifiedOnce(); + + await check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'newer content')) + .isA>().throws(); + check(connection.takeRequests()).isEmpty(); + })); + + test('event arrives, then request fails', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.flushTimers(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('request fails, then event arrives', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails, then event arrives; take failed edit in between', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); // content updated + })); + + test('request fails, then message deleted', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.deleteMessageEvent([message])); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('message deleted while request in progress; we get failure response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request failure, but status has already been cleared + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('message deleted while request in progress but we get success response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request success + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + }); + group('handleMessageEvent', () { 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/saved_snippet_test.dart b/test/model/saved_snippet_test.dart new file mode 100644 index 0000000000..3c6756f977 --- /dev/null +++ b/test/model/saved_snippet_test.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'store_checks.dart'; + +void main() { + test('handleSavedSnippetsEvent', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + savedSnippets: [eg.savedSnippet(id: 101)])); + check(store).savedSnippets.values.single.id.equals(101); + + await store.handleEvent(SavedSnippetsAddEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'foo title', + content: 'foo content', + ))); + check(store).savedSnippets.values.deepEquals(>[ + (it) => it.isA().id.equals(101), + (it) => it.isA()..id.equals(102) + ..title.equals('foo title') + ..content.equals('foo content') + ]); + + await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101)); + check(store).savedSnippets.values.single.id.equals(102); + + await store.handleEvent(SavedSnippetsUpdateEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'bar title', + content: 'bar content', + dateCreated: store.savedSnippets.values.single.dateCreated, + ))); + check(store).savedSnippets.values.single + ..id.equals(102) + ..title.equals('bar title') + ..content.equals('bar content'); + }); +} diff --git a/test/model/schemas/drift_schema_v7.json b/test/model/schemas/drift_schema_v7.json new file mode 100644 index 0000000000..28ceaac619 --- /dev/null +++ b/test/model/schemas/drift_schema_v7.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v8.json b/test/model/schemas/drift_schema_v8.json new file mode 100644 index 0000000000..62f8ca43d0 --- /dev/null +++ b/test/model/schemas/drift_schema_v8.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v9.json b/test/model/schemas/drift_schema_v9.json new file mode 100644 index 0000000000..e425bc89c8 --- /dev/null +++ b/test/model/schemas/drift_schema_v9.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"legacy_upgrade_state","getter_name":"legacyUpgradeState","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LegacyUpgradeState.values)","dart_type_name":"LegacyUpgradeState"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index d59002bf56..413b4408c4 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -9,6 +9,9 @@ import 'schema_v3.dart' as v3; import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; +import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; +import 'schema_v9.dart' as v9; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -26,10 +29,16 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v5.DatabaseAtV5(db); case 6: return v6.DatabaseAtV6(db); + case 7: + return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); + case 9: + return v9.DatabaseAtV9(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; } diff --git a/test/model/schemas/schema_v1.dart b/test/model/schemas/schema_v1.dart index a3f326e1d3..9629b868f7 100644 --- a/test/model/schemas/schema_v1.dart +++ b/test/model/schemas/schema_v1.dart @@ -95,45 +95,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ); } @@ -186,10 +179,9 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), ); } @@ -241,8 +233,9 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, ); AccountsData copyWithCompanion(AccountsCompanion data) { @@ -252,18 +245,15 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, ); } diff --git a/test/model/schemas/schema_v2.dart b/test/model/schemas/schema_v2.dart index f31a7934e0..61c69dd90c 100644 --- a/test/model/schemas/schema_v2.dart +++ b/test/model/schemas/schema_v2.dart @@ -103,45 +103,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -203,15 +196,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -265,11 +256,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -278,22 +271,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v3.dart b/test/model/schemas/schema_v3.dart index 7a78e85840..862ea42c18 100644 --- a/test/model/schemas/schema_v3.dart +++ b/test/model/schemas/schema_v3.dart @@ -57,10 +57,9 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), ); } @@ -88,10 +87,9 @@ class GlobalSettingsData extends DataClass ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, ); } @@ -264,45 +262,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -364,15 +355,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -426,11 +415,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -439,22 +430,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v4.dart b/test/model/schemas/schema_v4.dart index e53e4fbe2a..631d37ab82 100644 --- a/test/model/schemas/schema_v4.dart +++ b/test/model/schemas/schema_v4.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -311,45 +306,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -411,15 +399,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -473,11 +459,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -486,22 +474,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v5.dart b/test/model/schemas/schema_v5.dart index 3bf383ef27..1d3bc4d895 100644 --- a/test/model/schemas/schema_v5.dart +++ b/test/model/schemas/schema_v5.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -311,45 +306,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -411,15 +399,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -473,11 +459,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -486,22 +474,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v6.dart b/test/model/schemas/schema_v6.dart index 17ff55be21..aac90f3ae3 100644 --- a/test/model/schemas/schema_v6.dart +++ b/test/model/schemas/schema_v6.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -247,16 +242,14 @@ class BoolGlobalSettings extends Table BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BoolGlobalSettingsData( - name: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - )!, - value: - attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}value'], - )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, ); } @@ -499,45 +492,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -599,15 +585,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -661,11 +645,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -674,22 +660,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v7.dart b/test/model/schemas/schema_v7.dart new file mode 100644 index 0000000000..b74f391386 --- /dev/null +++ b/test/model/schemas/schema_v7.dart @@ -0,0 +1,921 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(themeSetting, browserPreference, visitFirstUnread); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV7 extends GeneratedDatabase { + DatabaseAtV7(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 7; +} diff --git a/test/model/schemas/schema_v8.dart b/test/model/schemas/schema_v8.dart new file mode 100644 index 0000000000..fb17863b15 --- /dev/null +++ b/test/model/schemas/schema_v8.dart @@ -0,0 +1,967 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 8; +} diff --git a/test/model/schemas/schema_v9.dart b/test/model/schemas/schema_v9.dart new file mode 100644 index 0000000000..d036e3a26f --- /dev/null +++ b/test/model/schemas/schema_v9.dart @@ -0,0 +1,1014 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + legacyUpgradeState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + legacyUpgradeState: serializer.fromJson( + json['legacyUpgradeState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'legacyUpgradeState': serializer.toJson(legacyUpgradeState), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV9 extends GeneratedDatabase { + DatabaseAtV9(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 9; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index ad739f5d4b..b4842ecd04 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -77,6 +77,12 @@ void main() { // TODO integration tests with sqlite }); + // TODO(#1571) test visitFirstUnread applies default + // TODO(#1571) test shouldVisitFirstUnread + + // TODO(#1583) test markReadOnScroll applies default + // TODO(#1583) test markReadOnScrollForNarrow + group('getBool/setBool', () { test('get from default', () { final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 32379a6f06..93e24dffdd 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -56,6 +56,7 @@ extension PerAccountStoreChecks on Subject { Subject get account => has((x) => x.account, 'account'); Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); Subject get userSettings => has((x) => x.userSettings, 'userSettings'); + Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); Subject> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index ef0a7a72be..68f9503fce 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -17,6 +17,7 @@ import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; import 'package:zulip/model/actions.dart'; +import 'package:zulip/model/presence.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -31,6 +32,7 @@ import 'test_store.dart'; void main() { TestZulipBinding.ensureInitialized(); + Presence.debugEnable = false; final account1 = eg.selfAccount.copyWith(id: 1); final account2 = eg.otherAccount.copyWith(id: 2); @@ -569,7 +571,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 +588,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(), }); }); }); @@ -705,7 +710,7 @@ void main() { final emojiDataUrl = Uri.parse('https://cdn.example/emoji.json'); final data = { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }; @@ -1290,8 +1295,8 @@ void main() { // (This is probably the common case.) addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); await NotificationService.instance.start(); // On store startup, send the token. @@ -1318,8 +1323,8 @@ void main() { // request for the token is still pending. addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); final startFuture = NotificationService.instance.start(); // TODO this test is a bit brittle in its interaction with asynchrony; diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 0196611e1d..d979e737f9 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -267,6 +267,10 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future setMutedUsers(List userIds) async { + await handleEvent(eg.mutedUsersEvent(userIds)); + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 63ac1589c7..27b07c129d 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -79,4 +79,27 @@ void main() { check(getUser()).deliveryEmail.equals('c@mail.example'); }); }); + + testWidgets('MutedUsersEvent', (tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [user1, user2, user3], + mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isFalse(); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store.isUserMuted(1)).isFalse(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + }); } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ccba7e24cc..c4763b27ef 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -6,7 +5,6 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:fake_async/fake_async.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart' hide Notification; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -18,24 +16,15 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; -import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; -import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/message_list.dart'; -import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/theme.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../model/binding.dart'; -import '../model/narrow_checks.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; -import '../test_navigation.dart'; -import '../widgets/dialog_checks.dart'; -import '../widgets/message_list_checks.dart'; -import '../widgets/page_checks.dart'; MessageFcmMessage messageFcmMessage( Message zulipMessage, { @@ -221,8 +210,8 @@ void main() { NotificationChannelManager.kDefaultNotificationSound.resourceName; String fakeStoredUrl(String resourceName) => testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(resourceName); - String fakeResourceUrl(String resourceName) => - 'android.resource://com.zulip.flutter/raw/$resourceName'; + String fakeResourceUrl({required String resourceName, String? packageName}) => + 'android.resource://${packageName ?? eg.packageInfo().packageName}/raw/$resourceName'; test('on Android 28 (and lower) resource file is used for notification sound', () async { addTearDown(testBinding.reset); @@ -238,7 +227,30 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); + }); + + test('generates resource file URL from app package name', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.example.test'); + + // Force the default sound URL to be the resource file URL, by forcing + // the Android version to the one where we don't store sounds through the + // media store. + testBinding.deviceInfoResult = + const AndroidDeviceInfo(sdkInt: 28, release: '9'); + + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeResourceUrl( + resourceName: defaultSoundResourceName, + packageName: 'com.example.test', + )); }); test('notification sound resource files are being copied to the media store', () async { @@ -326,7 +338,7 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); }); }); @@ -354,7 +366,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { @@ -1034,423 +1046,6 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); }); - - group('NotificationDisplayManager open', () { - late List> pushedRoutes; - - void takeStartingRoutes({Account? account, bool withAccount = true}) { - account ??= eg.selfAccount; - final expected = >[ - if (withAccount) - (it) => it.isA() - ..accountId.equals(account!.id) - ..page.isA() - else - (it) => it.isA().page.isA(), - ]; - check(pushedRoutes.take(expected.length)).deepEquals(expected); - pushedRoutes.removeRange(0, expected.length); - } - - Future prepare(WidgetTester tester, - {bool early = false, bool withAccount = true}) async { - await init(addSelfAccount: false); - pushedRoutes = []; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - // This uses [ZulipApp] instead of [TestZulipApp] because notification - // logic uses `await ZulipApp.navigator`. - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); - if (early) { - check(pushedRoutes).isEmpty(); - return; - } - await tester.pump(); - takeStartingRoutes(withAccount: withAccount); - check(pushedRoutes).isEmpty(); - } - - Future openNotification(WidgetTester tester, Account account, Message message) async { - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator - } - - void matchesNavigation(Subject> route, Account account, Message message) { - route.isA() - ..accountId.equals(account.id) - ..page.isA() - .initNarrow.equals(SendableNarrow.ofMessage(message, - selfUserId: account.userId)); - } - - Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { - await openNotification(tester, account, message); - matchesNavigation(check(pushedRoutes).single, account, message); - pushedRoutes.clear(); - } - - testWidgets('stream message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }); - - testWidgets('direct message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); - }); - - testWidgets('account queried by realmUrl origin component', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add( - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), - eg.initialSnapshot()); - await prepare(tester); - - await checkOpenNotification(tester, - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), - eg.streamMessage()); - await checkOpenNotification(tester, - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), - eg.streamMessage()); - }); - - testWidgets('no accounts', (tester) async { - await prepare(tester, withAccount: false); - await openNotification(tester, eg.selfAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('mismatching account', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await openNotification(tester, eg.otherAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('find account among several', (tester) async { - addTearDown(testBinding.reset); - final realmUrlA = Uri.parse('https://a-chat.example/'); - final realmUrlB = Uri.parse('https://chat-b.example/'); - final user1 = eg.user(); - final user2 = eg.user(); - final accounts = [ - eg.account(id: 1001, realmUrl: realmUrlA, user: user1), - eg.account(id: 1002, realmUrl: realmUrlA, user: user2), - eg.account(id: 1003, realmUrl: realmUrlB, user: user1), - eg.account(id: 1004, realmUrl: realmUrlB, user: user2), - ]; - for (final account in accounts) { - await testBinding.globalStore.add(account, eg.initialSnapshot()); - } - await prepare(tester); - - await checkOpenNotification(tester, accounts[0], eg.streamMessage()); - await checkOpenNotification(tester, accounts[1], eg.streamMessage()); - await checkOpenNotification(tester, accounts[2], eg.streamMessage()); - await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }); - - testWidgets('wait for app to become ready', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester, early: true); - final message = eg.streamMessage(); - await openNotification(tester, eg.selfAccount, message); - // The app should still not be ready (or else this test won't work right). - check(ZulipApp.ready.value).isFalse(); - check(ZulipApp.navigatorKey.currentState).isNull(); - // And the openNotification hasn't caused any navigation yet. - check(pushedRoutes).isEmpty(); - - // Now let the GlobalStore get loaded and the app's main UI get mounted. - await tester.pump(); - // The navigator first pushes the starting routes… - takeStartingRoutes(); - // … and then the one the notification leads to. - matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }); - - testWidgets('at app launch', (tester) async { - addTearDown(testBinding.reset); - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. - final account = eg.selfAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); - - // Now start the app. - await testBinding.globalStore.add(account, eg.initialSnapshot()); - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - // Once the app is ready, we navigate to the conversation. - await tester.pump(); - takeStartingRoutes(); - matchesNavigation(check(pushedRoutes).single, account, message); - }); - - testWidgets('uses associated account as initial account; if initial route', (tester) async { - addTearDown(testBinding.reset); - - final accountA = eg.selfAccount; - final accountB = eg.otherAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: accountB); - await testBinding.globalStore.add(accountA, eg.initialSnapshot()); - await testBinding.globalStore.add(accountB, eg.initialSnapshot()); - - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); - - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - await tester.pump(); - takeStartingRoutes(account: accountB); - matchesNavigation(check(pushedRoutes).single, accountB, message); - }); - }); - - group('NotificationOpenPayload', () { - test('smoke round-trip', () { - // DM narrow - var payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - - // Topic narrow - payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - }); - - test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - }); - - test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - }); - - test('parse: smoke DM', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..allRecipientIds.deepEquals([1001, 1002]) - ..otherRecipientIds.deepEquals([1002])); - }); - - test('parse: smoke topic', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..streamId.equals(1) - ..topic.equals(eg.t('topic A'))); - }); - - test('parse: fails when missing any expected query parameters', () { - final testCases = >[ - { - // 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - // 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - // 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - // 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - // 'all_recipient_ids': '1001,1002', - }, - ]; - for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: params, - ))) - // Missing 'realm_url', 'user_id' and 'narrow_type' - // throws 'FormatException'. - // Missing 'channel_id', 'topic', when narrow_type == 'topic' - // throws 'TypeError'. - // Missing 'all_recipient_ids', when narrow_type == 'dm' - // throws 'TypeError'. - .throws(); - } - }); - - test('parse: fails when scheme is not "zulip"', () { - final url = Uri( - scheme: 'http', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - - test('parse: fails when host is not "notification"', () { - final url = Uri( - scheme: 'zulip', - host: 'example', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - }); } extension on Subject { @@ -1530,9 +1125,3 @@ extension on Subject { Subject get notification => has((x) => x.notification, 'notification'); Subject get tag => has((x) => x.tag, 'tag'); } - -extension on Subject { - Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); - Subject get userId => has((x) => x.userId, 'userId'); - Subject get narrow => has((x) => x.narrow, 'narrow'); -} diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart new file mode 100644 index 0000000000..a2c14ca20a --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,577 @@ +import 'dart:async'; + +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/notifications.dart'; +import 'package:zulip/host/notifications.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/notifications/open.dart'; +import 'package:zulip/notifications/receive.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/narrow_checks.dart'; +import '../stdlib_checks.dart'; +import '../test_navigation.dart'; +import '../widgets/dialog_checks.dart'; +import '../widgets/message_list_checks.dart'; +import '../widgets/page_checks.dart'; +import 'display_test.dart'; + +Map messageApnsPayload( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + return { + "aps": { + "alert": { + "title": "test", + "subtitle": "test", + "body": zulipMessage.content, + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulip.example.cloud", + "realm_id": 4, + "realm_uri": account.realmUrl.toString(), + "realm_url": account.realmUrl.toString(), + "realm_name": "Test", + "user_id": account.userId, + "sender_id": zulipMessage.senderId, + "sender_email": zulipMessage.senderEmail, + "time": zulipMessage.timestamp, + "message_ids": [zulipMessage.id], + ...(switch (zulipMessage) { + StreamMessage(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId, + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmMessage(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": zulipMessage.allRecipientIds.join(","), + }, + DmMessage() => {"recipient_type": "private"}, + }), + }, + }; +} + +void main() { + TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future init({bool addSelfAccount = true}) async { + if (addSelfAccount) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationOpenService', () { + late List> pushedRoutes; + + void takeStartingRoutes({Account? account, bool withAccount = true}) { + account ??= eg.selfAccount; + final expected = >[ + if (withAccount) + (it) => it.isA() + ..accountId.equals(account!.id) + ..page.isA() + else + (it) => it.isA().page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare(WidgetTester tester, + {bool early = false, bool withAccount = true}) async { + await init(addSelfAccount: false); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + // This uses [ZulipApp] instead of [TestZulipApp] because notification + // logic uses `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + if (early) { + check(pushedRoutes).isEmpty(); + return; + } + await tester.pump(); + takeStartingRoutes(withAccount: withAccount); + check(pushedRoutes).isEmpty(); + } + + Uri androidNotificationUrlForMessage(Account account, Message message) { + final data = messageFcmMessage(message, account: account); + return NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildAndroidNotificationUrl(); + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final intentDataUrl = androidNotificationUrlForMessage(account, message); + unawaited( + WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); + await tester.idle(); // let navigateForNotification find navigator + + case TargetPlatform.iOS: + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.addNotificationTapEvent( + NotificationTapEvent(payload: payload)); + await tester.idle(); // let navigateForNotification find navigator + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } + } + + void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final intentDataUrl = androidNotificationUrlForMessage(account, message); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + + case TargetPlatform.iOS: + // Set up a value to return for + // `notificationPigeonApi.getNotificationDataFromLaunch`. + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { + await openNotification(tester, account, message); + matchesNavigation(check(pushedRoutes).single, account, message); + pushedRoutes.clear(); + } + + testWidgets('stream message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('direct message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('account queried by realmUrl origin component', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add( + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.initialSnapshot()); + await prepare(tester); + + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), + eg.streamMessage()); + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('no accounts', (tester) async { + await prepare(tester, withAccount: false); + await openNotification(tester, eg.selfAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('mismatching account', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await openNotification(tester, eg.otherAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('find account among several', (tester) async { + addTearDown(testBinding.reset); + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final user1 = eg.user(); + final user2 = eg.user(); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: user1), + eg.account(id: 1002, realmUrl: realmUrlA, user: user2), + eg.account(id: 1003, realmUrl: realmUrlB, user: user1), + eg.account(id: 1004, realmUrl: realmUrlB, user: user2), + ]; + for (final account in accounts) { + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + await prepare(tester); + + await checkOpenNotification(tester, accounts[0], eg.streamMessage()); + await checkOpenNotification(tester, accounts[1], eg.streamMessage()); + await checkOpenNotification(tester, accounts[2], eg.streamMessage()); + await checkOpenNotification(tester, accounts[3], eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('wait for app to become ready', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester, early: true); + final message = eg.streamMessage(); + await openNotification(tester, eg.selfAccount, message); + // The app should still not be ready (or else this test won't work right). + check(ZulipApp.ready.value).isFalse(); + check(ZulipApp.navigatorKey.currentState).isNull(); + // And the openNotification hasn't caused any navigation yet. + check(pushedRoutes).isEmpty(); + + // Now let the GlobalStore get loaded and the app's main UI get mounted. + await tester.pump(); + // The navigator first pushes the starting routes… + takeStartingRoutes(); + // … and then the one the notification leads to. + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('at app launch', (tester) async { + addTearDown(testBinding.reset); + final account = eg.selfAccount; + final message = eg.streamMessage(); + setupNotificationDataForLaunch(tester, account, message); + + // Now start the app. + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeStartingRoutes(); + matchesNavigation(check(pushedRoutes).single, account, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot()); + setupNotificationDataForLaunch(tester, accountB, message); + + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeStartingRoutes(account: accountB); + matchesNavigation(check(pushedRoutes).single, accountB, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + + group('NotificationOpenPayload', () { + test('android: smoke round-trip', () { + // DM narrow + var payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ); + var url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + + // Topic narrow + payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ); + url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + }); + + group('parseIosApnsPayload', () { + test('smoke one-one DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke group DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final userC = eg.user(userId: 1003); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002, 1003])); + }); + + test('smoke topic message', () { + final userA = eg.user(userId: 1001); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic A'), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(TopicName('topic A'))); + }); + }); + + group('buildAndroidNotificationUrl', () { + test('smoke DM', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + }); + + test('smoke topic', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + }); + }); + + group('parseAndroidNotificationUrl', () { + test('smoke DM', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..allRecipientIds.deepEquals([1001, 1002]) + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke topic', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(eg.t('topic A'))); + }); + + test('fails when missing any expected query parameters', () { + final testCases = >[ + { + // 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + // 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + // 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + // 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + // 'all_recipient_ids': '1001,1002', + }, + ]; + for (final params in testCases) { + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: params, + ))) + // Missing 'realm_url', 'user_id' and 'narrow_type' + // throws 'FormatException'. + // Missing 'channel_id', 'topic', when narrow_type == 'topic' + // throws 'TypeError'. + // Missing 'all_recipient_ids', when narrow_type == 'dm' + // throws 'TypeError'. + .throws(); + } + }); + + test('fails when scheme is not "zulip"', () { + final url = Uri( + scheme: 'http', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); + + test('fails when host is not "notification"', () { + final url = Uri( + scheme: 'zulip', + host: 'example', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); + }); + }); +} + +extension on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); + Subject get userId => has((x) => x.userId, 'userId'); + Subject get narrow => has((x) => x.narrow, 'narrow'); +} diff --git a/test/test_navigation.dart b/test/test_navigation.dart index b5065d684c..35da8af6b0 100644 --- a/test/test_navigation.dart +++ b/test/test_navigation.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; /// A trivial observer for testing the navigator. class TestNavigatorObserver extends NavigatorObserver { + void Function(Route topRoute, Route? previousTopRoute)? onChangedTop; void Function(Route route, Route? previousRoute)? onPushed; void Function(Route route, Route? previousRoute)? onPopped; void Function(Route route, Route? previousRoute)? onRemoved; @@ -13,6 +14,11 @@ class TestNavigatorObserver extends NavigatorObserver { void Function(Route route, Route? previousRoute)? onStartUserGesture; void Function()? onStopUserGesture; + @override + void didChangeTop(Route topRoute, Route? previousTopRoute) { + onChangedTop?.call(topRoute, previousTopRoute); + } + @override void didPush(Route route, Route? previousRoute) { onPushed?.call(route, previousRoute); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..b2a3ed1de0 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -23,6 +23,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/action_sheet.dart'; import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji.dart'; @@ -40,6 +41,7 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; @@ -52,24 +54,45 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? sender, + List? mutedUserIds, + bool? realmAllowMessageEditing, + int? realmMessageContentEditLimitSeconds, + bool shouldSetServerEmojiData = true, + bool useLegacyServerEmojiData = false, + Future Function()? beforeLongPress, }) async { addTearDown(testBinding.reset); - assert(narrow.containsMessage(message)); - - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + // TODO(#1667) will be null in a search narrow; remove `!`. + assert(narrow.containsMessage(message)!); + + await testBinding.globalStore.add( + eg.selfAccount, + eg.initialSnapshot( + realmAllowMessageEditing: realmAllowMessageEditing, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + )); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, - eg.user(userId: message.senderId), + sender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); await store.addSubscription(eg.subscription(stream)); } connection = store.connection as FakeApiConnection; + if (shouldSetServerEmojiData) { + store.setServerEmojiData(useLegacyServerEmojiData + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular); + } connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); @@ -79,15 +102,29 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - // request the message action sheet - await tester.longPress(find.byType(MessageContent)); + await beforeLongPress?.call(); + + // Request the message action sheet. + // + // We use `warnIfMissed: false` to suppress warnings in cases where + // MessageContent itself didn't hit-test as true but the action sheet still + // opened. The action sheet still opens because the gesture handler is an + // ancestor of MessageContent, but MessageContent might not hit-test as true + // because its render box effectively has HitTestBehavior.deferToChild, and + // the long-press might land where no child hit-tests as true, + // like if it's in padding around a Paragraph. + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); + // Check the action sheet did in fact open, so we don't defeat any tests that + // use simple `find.byIcon`-style checks to test presence/absence of a button. + check(find.byType(BottomSheet)).findsOne(); } void main() { TestZulipBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; void prepareRawContentResponseSuccess({ required Message message, @@ -200,6 +237,7 @@ void main() { group('showChannelActionSheet', () { void checkButtons() { check(actionSheetFinder).findsOne(); + checkButton('List of topics'); checkButton('Mark channel as read'); } @@ -218,7 +256,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 { @@ -242,6 +280,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() @@ -372,7 +423,6 @@ void main() { final topicRow = find.descendant( of: find.byType(ZulipAppBar), matching: find.text( - // ignore: dead_null_aware_expression // null topic names soon to be enabled effectiveTopic.displayName ?? eg.defaultRealmEmptyTopicDisplayName)); await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation @@ -393,7 +443,7 @@ void main() { await tester.longPress(find.descendant( of: find.byType(RecipientHeader), - matching: find.text(effectiveMessage.topic.displayName))); + matching: find.text(effectiveMessage.topic.displayName!))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); } @@ -457,7 +507,7 @@ void main() { messages: [message]); check(findButtonForLabel('Mark as resolved')).findsNothing(); check(findButtonForLabel('Mark as unresolved')).findsNothing(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show from recipient header', (tester) async { await prepare(); @@ -811,72 +861,96 @@ void main() { group('message action sheet', () { group('ReactionButtons', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; - - for (final emoji in popularCandidates) { - final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; - - Future tapButton(WidgetTester tester) async { - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.text(emojiDisplay.emojiUnicode))); - } - - testWidgets('${emoji.emojiName} adding success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); - }); - - testWidgets('${emoji.emojiName} removing success', (tester) async { - final message = eg.streamMessage( - reactions: [Reaction( - emojiName: emoji.emojiName, - emojiCode: emoji.emojiCode, - reactionType: ReactionType.unicodeEmoji, - userId: eg.selfAccount.userId)] - ); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('DELETE') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); - }); + testWidgets('absent if ServerEmojiData not loaded', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + shouldSetServerEmojiData: false); + check(find.byType(ReactionButtons)).findsNothing(); + }); - testWidgets('${emoji.emojiName} request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + for (final useLegacy in [false, true]) { + final popularCandidates = + (eg.store()..setServerEmojiData( + useLegacy + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } + + testWidgets('${emoji.emojiName} adding success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - connection.prepare( - apiException: eg.apiBadRequest(message: 'Invalid message(s)')); - await tapButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + testWidgets('${emoji.emojiName} removing success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', - expectedMessage: 'Invalid message(s)'))); - }); + testWidgets('${emoji.emojiName} request has an error; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + }); + } } }); @@ -1156,6 +1230,18 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); check(findQuoteAndReplyButton(tester)).isNull(); }); + + testWidgets('handle empty topic', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + check(connection.lastRequest).isA() + .url.queryParameters['allow_empty_topic_name'].equals('true'); + await tester.pump(Duration.zero); + }); }); group('MarkAsUnread', () { @@ -1259,6 +1345,79 @@ void main() { }); }); + group('UnrevealMutedMessageButton', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + final revealButtonFinder = find.widgetWithText(ZulipWebUiKitButton, + 'Reveal message'); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + testWidgets('not visible if message is from normal sender (not muted)', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user); + check(store.isUserMuted(user.userId)).isFalse(); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('visible if message is from muted sender and revealed', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('when pressed, unreveals the message', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }); + + await tester.ensureVisible(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.eye_off)); + await tester.pumpAndSettle(); + + check(contentFinder).findsNothing(); + check(revealButtonFinder).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( @@ -1410,6 +1569,169 @@ void main() { }); }); + group('EditButton', () { + Future tapEdit(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + group('present/absent appropriately', () { + /// Test whether the edit-message button is visible, given params. + /// + /// The message timestamp is 60s before the current time + /// ([TestZulipBinding.utcNow]) as of the start of the test run. + /// + /// The message has streamId: 1 and topic: 'topic'. + /// The message list is for that [TopicNarrow] unless [narrow] is passed. + void testVisibility(bool expected, { + bool self = true, + Narrow? narrow, + bool allowed = true, + int? limit, + bool boxInEditMode = false, + bool? errorStatus, + bool poll = false, + }) { + // It's inconvenient here to set up a state where the compose box + // is in edit mode and the action sheet is opened for a message + // with an edit request that's in progress or in the error state. + // In the setup, we'd need to either use two messages or (via an edge + // case) two MessageListPages. It should suffice to test the + // boxInEditMode and errorStatus states separately. + assert(!boxInEditMode || errorStatus == null); + + final description = [ + 'from self: $self', + 'narrow: $narrow', + 'realm allows: $allowed', + 'edit limit: $limit', + 'compose box is in editing mode: $boxInEditMode', + 'edit-message error status: $errorStatus', + 'has poll: $poll', + ].join(', '); + + void checkButtonIsPresent(bool expected) { + if (expected) { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsOne(); + } else { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsNothing(); + } + } + + testWidgets(description, (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final message = eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic', + sender: self ? eg.selfUser : eg.otherUser, + timestamp: eg.utcTimestamp(testBinding.utcNow()) - 60, + submessages: poll + ? [eg.submessage(content: eg.pollWidgetData(question: 'poll', options: ['A']))] + : null, + ); + + await setupToMessageActionSheet(tester, + message: message, + narrow: narrow ?? TopicNarrow.ofMessage(message), + realmAllowMessageEditing: allowed, + realmMessageContentEditLimitSeconds: limit, + ); + + if (!boxInEditMode && errorStatus == null) { + // The state we're testing is present on the original action sheet. + checkButtonIsPresent(expected); + return; + } + // The state we're testing requires a previous "edit message" action + // in order to set up. Use the first action sheet for that setup step. + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(Duration.zero); + await tester.enterText(find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == 'foo'), + 'bar'); + + if (errorStatus == true) { + // We're testing the request-failed state. Prepare a failure + // and tap Save. + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + } else if (errorStatus == false) { + // We're testing the request-in-progress state. Prepare a delay, + // tap Save, and wait through only part of the delay. + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration(milliseconds: 500)); + } else { + // We're testing the state where the compose box is in + // edit-message mode. Keep it that way by not tapping Save. + } + + // See comment in setupToMessageActionSheet about warnIfMissed: false + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + checkButtonIsPresent(expected); + + await tester.pump(Duration(milliseconds: 500)); // flush timers + }); + } + + testVisibility(true); + // TODO(server-6) limit 0 not expected on 6.0+ + testVisibility(true, limit: 0); + testVisibility(true, limit: 600); + testVisibility(true, narrow: ChannelNarrow(1)); + + testVisibility(false, self: false); + testVisibility(false, narrow: CombinedFeedNarrow()); + testVisibility(false, allowed: false); + testVisibility(false, limit: 10); + testVisibility(false, boxInEditMode: true); + testVisibility(false, errorStatus: false); + testVisibility(false, errorStatus: true); + testVisibility(false, poll: true); + }); + + group('tap button', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(sender: eg.selfUser); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, + ); + + check(findComposeBoxController(tester)) + .isA(); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(Duration.zero); + + check(findComposeBoxController(tester)) + .isA() + ..messageId.equals(message.id) + ..originalRawContent.equals('foo'); + }); + }); + }); + group('MessageActionSheetCancelButton', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 95e79d9441..6810092f86 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -21,7 +21,6 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; -import '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; import 'dialog_checks.dart'; @@ -119,106 +118,6 @@ void main() { await future; check(store.unreads.oldUnreadsMissing).isFalse(); }); - - testWidgets('CombinedFeedNarrow on legacy server', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - // Might as well test with oldUnreadsMissing: true. - store.unreads.oldUnreadsMissing = true; - - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals({}); - - // Check that [Unreads.handleAllMessagesReadSuccess] wasn't called; - // in the legacy protocol, that'd be redundant with the mark-read event. - check(store.unreads).oldUnreadsMissing.isTrue(); - }); - - testWidgets('ChannelNarrow on legacy server', (tester) async { - final stream = eg.stream(); - final narrow = ChannelNarrow(stream.streamId); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals({ - 'stream_id': stream.streamId.toString(), - }); - }); - - testWidgets('TopicNarrow on legacy server', (tester) async { - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals({ - 'stream_id': narrow.streamId.toString(), - 'topic_name': narrow.topic, - }); - }); - - testWidgets('DmNarrow on legacy server', (tester) async { - final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); - final unreadMsgs = eg.unreadMsgs(dms: [ - UnreadDmSnapshot(otherUserId: eg.otherUser.userId, - unreadMessageIds: [message.id]), - ]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); - - testWidgets('MentionsNarrow on legacy server', (tester) async { - const narrow = MentionsNarrow(); - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - final unreadMsgs = eg.unreadMsgs(mentions: [message.id]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); }); group('updateMessageFlagsStartingFromAnchor', () { diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 484c3b2454..573921b663 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -145,6 +145,7 @@ typedef ExpectedEmoji = (String label, EmojiDisplay display); void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; group('@-mentions', () { @@ -415,7 +416,7 @@ void main() { await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name.displayName); + .equals(topic3.name.displayName!); check(find.text('Topic one' )).findsNothing(); check(find.text('Topic two' )).findsNothing(); check(find.text('Topic three')).findsOne(); // shown in `_TopicInput` once @@ -473,7 +474,7 @@ void main() { await tester.pumpAndSettle(); check(find.text('some display name')).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('match realmEmptyTopicDisplayName in autocomplete', (tester) async { final topic = eg.getStreamTopicsEntry(name: ''); @@ -486,7 +487,7 @@ void main() { await tester.pumpAndSettle(); check(find.text('general chat')).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('autocomplete to realmEmptyTopicDisplayName sets topic to empty string', (tester) async { final topic = eg.getStreamTopicsEntry(name: ''); @@ -502,6 +503,6 @@ void main() { await tester.tap(find.text('general chat')); await tester.pump(Duration.zero); check(controller.value).text.equals(''); - }, skip: true); // null topic names soon to be enabled + }); }); } diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart index da9136b8a2..62f2fad7d1 100644 --- a/test/widgets/button_test.dart +++ b/test/widgets/button_test.dart @@ -16,72 +16,84 @@ void main() { TestZulipBinding.ensureInitialized(); group('ZulipWebUiKitButton', () { - final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); - - testWidgets('vertical outer padding is preserved as text scales', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton(label: 'Cancel', onPressed: () {})))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) - .clamp(maxScaleFactor: 1.5); - final expectedButtonHeight = max(28.0, // configured min height - (textScaler.scale(17) * 1.20).roundToDouble() // text height - + 4 + 4); // vertical padding - - // Rounded rectangle paints with the intended height… - final expectedRRect = RRect.fromLTRBR( - 0, 0, // zero relative to the position at this paint step - size.width, expectedButtonHeight, Radius.circular(4)); - check(renderObject).legacyMatcher( - // `paints` isn't a [Matcher] so we wrap it with `equals`; - // awkward but it works - equals(paints..drrect(outer: expectedRRect))); - - // …and that height leaves at least 4px for vertical outer padding. - check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); - }, variant: textScaleFactorVariants); - - testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - int numTapsHandled = 0; - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton( - label: 'Cancel', - onPressed: () => numTapsHandled++)))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - // Outer padding responds to taps, not just the painted part. - final buttonCenter = tester.getCenter(buttonFinder); - int numTaps = 0; - for (double y = -22; y < 22; y++) { - await tester.tapAt(buttonCenter + Offset(0, y)); - numTaps++; - } - check(numTapsHandled).equals(numTaps); - }, variant: textScaleFactorVariants); + void testVerticalOuterPadding({required ZulipWebUiKitButtonSize sizeVariant}) { + final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); + T forSizeVariant(T small, T normal) => + switch (sizeVariant) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + + testWidgets('vertical outer padding is preserved as text scales; $sizeVariant', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () {}, + size: sizeVariant)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) + .clamp(maxScaleFactor: 1.5); + final expectedButtonHeight = max(forSizeVariant(24.0, 28.0), // configured min height + (textScaler.scale(forSizeVariant(16, 17) * forSizeVariant(1, 1.20)).roundToDouble() // text height + + 4 + 4)); // vertical padding + + // Rounded rectangle paints with the intended height… + final expectedRRect = RRect.fromLTRBR( + 0, 0, // zero relative to the position at this paint step + size.width, expectedButtonHeight, Radius.circular(forSizeVariant(6, 4))); + check(renderObject).legacyMatcher( + // `paints` isn't a [Matcher] so we wrap it with `equals`; + // awkward but it works + equals(paints..drrect(outer: expectedRRect))); + + // …and that height leaves at least 4px for vertical outer padding. + check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); + }, variant: textScaleFactorVariants); + + testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + int numTapsHandled = 0; + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () => numTapsHandled++)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + // Outer padding responds to taps, not just the painted part. + final buttonCenter = tester.getCenter(buttonFinder); + int numTaps = 0; + for (double y = -22; y < 22; y++) { + await tester.tapAt(buttonCenter + Offset(0, y)); + numTaps++; + } + check(numTapsHandled).equals(numTaps); + }, variant: textScaleFactorVariants); + } + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.small); + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.normal); }); } diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart index 8008b510d3..349e8cd971 100644 --- a/test/widgets/compose_box_checks.dart +++ b/test/widgets/compose_box_checks.dart @@ -1,6 +1,26 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; import 'package:zulip/widgets/compose_box.dart'; +extension ComposeBoxStateChecks on Subject { + Subject get controller => has((c) => c.controller, 'controller'); +} + +extension ComposeBoxControllerChecks on Subject { + Subject get content => has((c) => c.content, 'content'); + Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); +} + +extension StreamComposeBoxControllerChecks on Subject { + Subject get topic => has((c) => c.topic, 'topic'); + Subject get topicFocusNode => has((c) => c.topicFocusNode, 'topicFocusNode'); +} + +extension EditMessageComposeBoxControllerChecks on Subject { + Subject get messageId => has((c) => c.messageId, 'messageId'); + Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); +} + extension ComposeContentControllerChecks on Subject { Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 65740a8d1e..d1f9c33484 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,23 +3,28 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:zulip/api/model/events.dart'; 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'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/theme.dart'; @@ -32,14 +37,20 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../model/typing_status_test.dart'; import '../stdlib_checks.dart'; +import 'compose_box_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; + late ComposeBoxState state; + + // Caution: when testing edit-message UI, this will often be stale; + // read state.controller instead. late ComposeBoxController? controller; Future prepareComposeBox(WidgetTester tester, { @@ -47,14 +58,23 @@ void main() { User? selfUser, List otherUsers = const [], List streams = const [], + List? messages, bool? mandatoryTopics, int? zulipFeatureLevel, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { - assert(streams.any((stream) => stream.streamId == streamId), + final channel = streams.firstWhereOrNull((s) => s.streamId == streamId); + assert(channel != null, 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + if (narrow is ChannelNarrow) { + // By default, bypass the complexity where the topic input is autofocused + // on an empty fetch, by making the fetch not empty. (In particular that + // complexity includes a getStreamTopics fetch for topic autocomplete.) + messages ??= [eg.streamMessage(stream: channel)]; + } } addTearDown(testBinding.reset); + messages ??= []; selfUser ??= eg.selfUser; zulipFeatureLevel ??= eg.futureZulipFeatureLevel; final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); @@ -63,23 +83,27 @@ void main() { streams: streams, zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, )); store = await testBinding.globalStore.perAccount(selfAccount.id); connection = store.connection as FakeApiConnection; + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + if (narrow is ChannelNarrow && messages.isEmpty) { + // The topic input will autofocus, triggering a getStreamTopics request. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, - child: Column( - // This positions the compose box at the bottom of the screen, - // simulating the layout of the message list page. - children: [ - const Expanded(child: SizedBox.expand()), - ComposeBox(narrow: narrow), - ]))); + child: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); + connection.takeRequests(); - controller = tester.state(find.byType(ComposeBox)).controller; + state = tester.state(find.byType(ComposeBox)); + controller = state.controller; } /// A [Finder] for the topic input. @@ -117,12 +141,72 @@ void main() { .controller.isNotNull().value.text.equals(expected); } + final sendButtonFinder = find.byIcon(ZulipIcons.send); + Future tapSendButton(WidgetTester tester) async { connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); } + group('auto focus', () { + testWidgets('ChannelNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel)]); + check(controller).isA() + ..topicFocusNode.hasFocus.isFalse() + ..contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('ChannelNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: []); + check(controller).isA() + .topicFocusNode.hasFocus.isTrue(); + }); + + testWidgets('TopicNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic')]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('TopicNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('DmNarrow, non-empty fetch', (tester) async { + final user = eg.user(); + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(user.userId, selfUserId: eg.selfUser.userId), + messages: [eg.dmMessage(from: user, to: [eg.selfUser])]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('DmNarrow, empty fetch', (tester) async { + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(eg.user().userId, selfUserId: eg.selfUser.userId), + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + }); + group('ComposeBoxTheme', () { test('lerp light to dark, no crash', () { final a = ComposeBoxTheme.light; @@ -232,6 +316,33 @@ void main() { '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); + + group('ContentValidationError.empty', () { + late ComposeContentController controller; + + void checkCountsAsEmpty(String text, bool expected) { + controller.value = TextEditingValue(text: text); + expected + ? check(controller).validationErrors.contains(ContentValidationError.empty) + : check(controller).validationErrors.not((it) => it.contains(ContentValidationError.empty)); + } + + testWidgets('requireNotEmpty: true (default)', (tester) async { + controller = ComposeContentController(); + addTearDown(controller.dispose); + checkCountsAsEmpty('', true); + checkCountsAsEmpty(' ', true); + checkCountsAsEmpty('a', false); + }); + + testWidgets('requireNotEmpty: false', (tester) async { + controller = ComposeContentController(requireNotEmpty: false); + addTearDown(controller.dispose); + checkCountsAsEmpty('', false); + checkCountsAsEmpty(' ', false); + checkCountsAsEmpty('a', false); + }); + }); }); group('length validation', () { @@ -258,6 +369,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]); @@ -295,6 +408,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]); @@ -372,7 +487,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name}'); }); @@ -382,7 +498,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic (skip for “(no topic)”)', contentHintText: 'Message #${channel.name}'); }); @@ -391,6 +507,40 @@ void main() { await enterTopic(tester, narrow: narrow, topic: eg.defaultRealmEmptyTopicDisplayName); await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, topic input has focus, then content input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + + testWidgets('with empty topic, topic input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', contentHintText: 'Message #${channel.name}'); @@ -401,10 +551,12 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: eg.defaultRealmEmptyTopicDisplayName, contentHintText: 'Message #${channel.name} > ' '${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.equals(FontStyle.italic); + }); testWidgets('legacy: with empty topic, content input has focus', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false, @@ -412,8 +564,44 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: '(no topic)', contentHintText: 'Message #${channel.name} > (no topic)'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.isNull(); + }); + + testWidgets('with empty topic, content input has focus, then topic input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, content input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); }); testWidgets('with non-empty topic', (tester) async { @@ -421,7 +609,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: 'new topic'); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name} > new topic'); }); }); @@ -489,7 +678,7 @@ void main() { narrow: TopicNarrow(channel.streamId, TopicName(''))); checkComposeBoxHintTexts(tester, contentHintText: 'Message #${channel.name} > ${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + }); }); testWidgets('to DmNarrow with self', (tester) async { @@ -612,13 +801,15 @@ 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); connection.prepare(json: {}); connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); final requests = connection.takeRequests(); checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); @@ -718,6 +909,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'), @@ -772,6 +965,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); @@ -782,7 +977,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: topicInputText); await tester.enterText(contentInputFinder, 'test content'); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(); } @@ -839,7 +1034,7 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), + of: sendButtonFinder, matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); @@ -1260,6 +1455,14 @@ void main() { await enterContent(tester, 'some content'); checkContentInputValue(tester, 'some content'); + // Encache a new connection; prepare it for the message-list fetch + final newConnection = (testBinding.globalStore + ..clearCachedApiConnections() + ..useCachedApiConnections = true) + .apiConnectionFromAccount(store.account) as FakeApiConnection; + newConnection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + store.updateMachine! ..debugPauseLoop() ..poll() @@ -1267,6 +1470,7 @@ void main() { eg.apiExceptionBadEventQueueId(queueId: store.queueId)) ..debugAdvanceLoop(); await tester.pump(); + await tester.pump(Duration.zero); final newStore = testBinding.globalStore.perAccountSync(store.accountId)!; check(newStore) @@ -1280,4 +1484,641 @@ void main() { checkContentInputValue(tester, 'some content'); }); }); + + /// 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('restoreMessageNotSent', () { + final channel = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + + final failedMessageContent = 'failed message'; + final failedMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, failedMessageContent, skipOffstage: true); + + Future prepareMessageNotSent(WidgetTester tester, { + required Narrow narrow, + List otherUsers = const [], + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], otherUsers: otherUsers); + + if (narrow is ChannelNarrow) { + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await enterTopic(tester, narrow: narrow, topic: topic); + } + await enterContent(tester, failedMessageContent); + connection.prepare(httpException: SocketException('error')); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + check(state).controller.content.text.equals(''); + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent'))); + await tester.pump(); + check(failedMessageFinder).findsOne(); + } + + testWidgets('restore content in DM narrow', (tester) async { + final dmNarrow = DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId); + await prepareMessageNotSent(tester, narrow: dmNarrow, otherUsers: [eg.otherUser]); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content in topic narrow', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content and topic in channel narrow', (tester) async { + final channelNarrow = ChannelNarrow(channel.streamId); + await prepareMessageNotSent(tester, narrow: channelNarrow); + + await tester.enterText(topicInputFinder, 'topic before restoring'); + check(state).controller.isA() + ..topic.text.equals('topic before restoring') + ..content.text.isNotNull().isEmpty(); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.isA() + ..topic.text.equals(topic) + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + Future expectAndHandleDiscardForMessageNotSentConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you restore an unsent message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + testWidgets('interrupting new-message compose: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting new-message compose: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + }); + + testWidgets('interrupting message edit: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting message edit: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + }); + }); + + group('edit message', () { + final channel = eg.stream(); + final topic = 'topic'; + final message = eg.streamMessage(sender: eg.selfUser, stream: channel, topic: topic); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + + final channelNarrow = ChannelNarrow(channel.streamId); + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId); + + Message msgInNarrow(Narrow narrow) { + final List messages = [message, dmMessage]; + return messages.where( + // TODO(#1667) will be null in a search narrow; remove `!`. + (m) => narrow.containsMessage(m)! + ).single; + } + + 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]); + await store.addMessages([message, dmMessage]); + await tester.pump(); // message list updates + } + + /// Check that the compose box is in the "Preparing…" state, + /// awaiting the fetch-raw-content request. + Future checkAwaitingRawMessageContent(WidgetTester tester) async { + check(state.controller) + .isA() + ..originalRawContent.isNull() + ..content.value.text.equals(''); + check(tester.widget(contentInputFinder)) + .isA() + .decoration.isNotNull().hintText.equals('Preparing…'); + checkContentInputValue(tester, ''); + + // Controls are disabled + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(); + check(testBinding.takePickFilesCalls()).isEmpty(); + + // Save button is disabled + final lastRequest = connection.lastRequest; + await tester.tap( + find.widgetWithText(ZulipWebUiKitButton, 'Save'), warnIfMissed: false); + await tester.pump(Duration.zero); + check(connection.lastRequest).equals(lastRequest); + } + + /// Starts an interaction by tapping a failed edit in the message list. + Future startInteractionFromRestoreFailedEdit( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + String newContent = 'bar', + }) async { + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: originalRawContent); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, newContent); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + connection.takeRequests(); + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + /// Check that the compose box is not in editing mode. + void checkNotInEditingMode(WidgetTester tester, { + required Narrow narrow, + String expectedContentText = '', + }) { + switch (narrow) { + case ChannelNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + case TopicNarrow(): + case DmNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + default: + throw StateError('unexpected narrow type'); + } + checkContentInputValue(tester, expectedContentText); + } + + void testSmoke({required Narrow narrow, required _EditInteractionStart start}) { + testWidgets('smoke: $narrow, ${start.message()}', (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startEditInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + originalRawContent: 'foo', + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + // Now that we have the raw content, check the input is interactive + // but no typing notifications are sent… + check(TypingNotifier.debugEnable).isTrue(); + check(state).controller.contentFocusNode.hasFocus.isTrue(); + await enterContent(tester, 'some new content'); + check(connection.takeRequests()).isEmpty(); + + // …and the upload buttons work. + testBinding.pickFilesResult = FilePickerResult([ + PlatformFile(name: 'file.jpg', size: 1000, readStream: Stream.fromIterable(['asdf'.codeUnits]))]); + connection.prepare(json: + UploadFileResult(uri: '/path/file.jpg').toJson()); + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(Duration.zero); + checkNoErrorDialog(tester); + check(testBinding.takePickFilesCalls()).length.equals(1); + connection.takeRequests(); // upload request + + // TODO could also check that quote-and-reply and autocomplete work + // (but as their own test cases, for a single narrow and start) + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, + prevContent: 'foo', content: 'some new content[file.jpg](/path/file.jpg)'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + + Future expectAndHandleDiscardForEditConfirmation(WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + // Test the "Discard…?" confirmation dialog when you tap "Edit message" in + // the action sheet but there's text in the compose box for a new message. + void testInterruptComposingFromActionSheet({required Narrow narrow}) { + testWidgets('interrupting new-message compose: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + 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 + connection.clearPreparedResponses(); + + // Twiddle the input to make sure it still works + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + await enterContent(tester, 'composing new message…'); + checkContentInputValue(tester, 'composing new message…'); + + // Try again, but this time tap Discard and expect to enter an edit session + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); + await tester.pump(); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + check(connection.takeRequests()).length.equals(1); + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'bar'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'bar'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Cover multiple narrows, checking that the Discard button resets the state + // correctly for each one. + testInterruptComposingFromActionSheet(narrow: channelNarrow); + testInterruptComposingFromActionSheet(narrow: topicNarrow); + testInterruptComposingFromActionSheet(narrow: dmNarrow); + + // Test the "Discard…?" confirmation dialog when you want to restore + // a failed edit but there's text in the compose box for a new message. + void testInterruptComposingFromFailedEdit({required Narrow narrow}) { + testWidgets('interrupting new-message compose by tapping failed edit to restore: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, 'bar'); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + connection.takeRequests(); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + check(find.text('EDIT NOT SAVED')).findsOne(); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + + // Twiddle the input to make sure it still works + await enterContent(tester, 'composing new message…'); + + // 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 expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); + await tester.pump(); + checkContentInputValue(tester, 'bar'); + await enterContent(tester, 'baz'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'baz'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // (So tests run faster, skip some narrows that are already covered above.) + testInterruptComposingFromFailedEdit(narrow: channelNarrow); + // testInterruptComposingFromFailedEdit(narrow: topicNarrow); + // testInterruptComposingFromFailedEdit(narrow: dmNarrow); + + // TODO also test: + // - Restore a failed edit, but when there's compose input for an edit- + // message session. (The failed edit would be for a different message, + // or else started from a different MessageListPage.) + + void testFetchRawContentFails({required Narrow narrow}) { + final description = 'fetch-raw-content fails: $narrow'; + testWidgets(description, (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + await startEditInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo', + fetchShouldSucceed: false); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkErrorDialog(tester, expectedTitle: 'Could not edit message'); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + // testFetchRawContentFails(narrow: channelNarrow); + testFetchRawContentFails(narrow: topicNarrow); + // testFetchRawContentFails(narrow: dmNarrow); + + /// Test that an edit session is really cleared by the Cancel button. + /// + /// If `start: _EditInteractionStart.actionSheet` (the default), + /// pass duringFetchRawContentRequest to control whether the Cancel button + /// is tapped during (true) or after (false) the fetch-raw-content request. + /// + /// If `start: _EditInteractionStart.restoreFailedEdit`, + /// don't pass duringFetchRawContentRequest. + void testCancel({ + required Narrow narrow, + _EditInteractionStart start = _EditInteractionStart.actionSheet, + bool? duringFetchRawContentRequest, + }) { + final description = StringBuffer()..write('tap Cancel '); + switch (start) { + case _EditInteractionStart.actionSheet: + assert(duringFetchRawContentRequest != null); + description + ..write(duringFetchRawContentRequest! ? 'during ' : 'after ') + ..write('fetch-raw-content request: '); + case _EditInteractionStart.restoreFailedEdit: + assert(duringFetchRawContentRequest == null); + description.write('when editing from a restored failed edit: '); + } + description.write('$narrow'); + testWidgets(description.toString(), (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startEditInteractionFromActionSheet(tester, + messageId: messageId, delay: Duration(seconds: 5)); + await checkAwaitingRawMessageContent(tester); + await tester.pump(duringFetchRawContentRequest! + ? Duration(milliseconds: 500) + : Duration(seconds: 5)); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Cancel')); + await tester.pump(); + checkNotInEditingMode(tester, narrow: narrow); + + // We've canceled the previous edit session, so we should be able to + // do a new edit-message session… + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'qwerty'); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'qwerty'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + + // …or send a new message. + connection.prepare(json: {}); // for typing-start request + connection.prepare(json: {}); // for typing-stop request + await enterContent(tester, 'new message to send'); + state.controller.contentFocusNode.unfocus(); + await tester.pump(); + check(connection.takeRequests()).deepEquals(>[ + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing'), + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing')]); + if (narrow is ChannelNarrow) { + await enterTopic(tester, narrow: narrow, topic: topic); + } + await tester.pump(); + await tapSendButton(tester); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages'); + checkContentInputValue(tester, ''); + + if (start == _EditInteractionStart.actionSheet && duringFetchRawContentRequest!) { + // Await the fetch-raw-content request from the canceled edit session; + // its completion shouldn't affect anything. + await tester.pump(Duration(seconds: 5)); + } + checkNotInEditingMode(tester, narrow: narrow); + check(connection.takeRequests()).isEmpty(); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + testCancel(narrow: channelNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: topicNarrow, duringFetchRawContentRequest: false); + testCancel(narrow: dmNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: channelNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: topicNarrow, duringFetchRawContentRequest: true); + // testCancel(narrow: dmNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + }); +} + +/// How the edit interaction is started: +/// from the action sheet, or by restoring a failed edit. +enum _EditInteractionStart { + actionSheet, + restoreFailedEdit; + + String message() { + return switch (this) { + _EditInteractionStart.actionSheet => 'from action sheet', + _EditInteractionStart.restoreFailedEdit => 'from restoring a failed edit', + }; + } } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index a788225aac..9ed263fce9 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -596,9 +596,10 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (final katexNode in nodes) { + for (var katexNode in nodes) { + katexNode = katexNode as KatexSpanNode; final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', @@ -639,12 +640,12 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; var nodes = baseNode.nodes!.skip(1); // Skip .strut node. final fontSize = kBaseKatexTextStyle.fontSize!; - final firstNode = nodes.first; + final firstNode = nodes.first as KatexSpanNode; checkKatexText(tester, firstNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, @@ -652,14 +653,17 @@ void main() { nodes = nodes.skip(1); for (var katexNode in nodes) { - katexNode = katexNode.nodes!.single; // Skip empty .mord parent. + katexNode = katexNode as KatexSpanNode; + katexNode = katexNode.nodes!.single as KatexSpanNode; // Skip empty .mord parent. final fontFamily = katexNode.styles.fontFamily!; checkKatexText(tester, katexNode.text!, fontFamily: fontFamily, fontSize: fontSize, fontHeight: kBaseKatexTextStyle.height!); } - }); + }, skip: true); // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1032,6 +1036,8 @@ void main() { .page.isA().initNarrow.equals(const ChannelNarrow(1)); }); + // TODO(#1570): test links with /near/ go to the specific message + testWidgets('invalid internal links are opened in browser', (tester) async { // Link is invalid due to `topic` operator missing an operand. final pushedRoutes = await prepare(tester, @@ -1161,6 +1167,26 @@ void main() { }); }); + group('InlineAudio', () { + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + // We try to resolve relative links on the self-account's realm. + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('tapping on audio link opens it in browser', (tester) async { + final url = eg.realmUrl.resolve('/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3'); + await prepare(tester, ContentExample.audioInline.html); + + await tapText(tester, find.text('crab-rave.mp3')); + + final expectedLaunchMode = defaultTargetPlatform == TargetPlatform.iOS ? + LaunchMode.externalApplication : LaunchMode.inAppBrowserView; + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: expectedLaunchMode)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + group('MessageImageEmoji', () { Future prepare(WidgetTester tester, String html) async { await prepareContent(tester, plainContent(html), diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart index c86aae478e..1980f619f3 100644 --- a/test/widgets/dialog_test.dart +++ b/test/widgets/dialog_test.dart @@ -73,4 +73,6 @@ void main() { await check(dialog.result).completes((it) => it.equals(null)); }); }); + + // TODO(#1594): test UpgradeWelcomeDialog } diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index d8faed191f..6bfa5e2fac 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -36,6 +36,7 @@ import 'text_test.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -161,7 +162,7 @@ void main() { // Base JSON for various unicode emoji reactions. Just missing user_id. final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; - final u3 = {'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; + final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; @@ -227,6 +228,27 @@ void main() { } } } + + testWidgets('show "Muted user" label for muted reactors', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + await prepare(); + await store.addUsers([user1, user2]); + await store.setMutedUsers([user1.userId]); + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user1.userId}), + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user2.userId}), + ]); + + final reactionChipFinder = find.byType(ReactionChip); + check(reactionChipFinder).findsOne(); + check(find.descendant( + of: reactionChipFinder, + matching: find.text('Muted user, User 2') + )).findsOne(); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { @@ -239,7 +261,7 @@ void main() { await setupChipsInBox(tester, reactions: [ Reaction.fromJson({ 'user_id': eg.selfUser.userId, - 'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), + 'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), Reaction.fromJson({ 'user_id': eg.otherUser.userId, 'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}), @@ -251,7 +273,7 @@ void main() { return material.color; } - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgUnselected); @@ -261,13 +283,13 @@ void main() { await tester.pump(kThemeAnimationDuration * 0.4); final expectedLerped = EmojiReactionTheme.light.lerp(EmojiReactionTheme.dark, 0.4); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(expectedLerped.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(expectedLerped.bgUnselected); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgUnselected); @@ -299,14 +321,17 @@ void main() { // - Non-animated image emoji is selected when intended group('EmojiPicker', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = + (eg.store()..setServerEmojiData(eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); Future setupEmojiPicker(WidgetTester tester, { required StreamMessage message, required Narrow narrow, }) async { addTearDown(testBinding.reset); - assert(narrow.containsMessage(message)); + // TODO(#1667) will be null in a search narrow; remove `!`. + assert(narrow.containsMessage(message)!); final httpClient = FakeImageHttpClient(); debugNetworkImageHttpClientProvider = () => httpClient; @@ -330,6 +355,11 @@ void main() { await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage(initNarrow: narrow))); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); + // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); // request the message action sheet @@ -337,9 +367,6 @@ void main() { // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) - })); await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), })); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 3c8db1dfcf..1b5c8ad8b5 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -34,10 +34,25 @@ void main () { late PerAccountStore store; late FakeApiConnection connection; - Future prepare(WidgetTester tester, { - NavigatorObserver? navigatorObserver, - }) async { + late Route? topRoute; + late Route? previousTopRoute; + late List> pushedRoutes; + late Route? lastPoppedRoute; + + final testNavObserver = TestNavigatorObserver() + ..onChangedTop = ((current, previous) { + topRoute = current; + previousTopRoute = previous; + }) + ..onPushed = ((route, prevRoute) => pushedRoutes.add(route)) + ..onPopped = ((route, prevRoute) => lastPoppedRoute = route); + + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -45,7 +60,7 @@ void main () { await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, - navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + navigatorObservers: [testNavObserver], child: const HomePage())); await tester.pump(); } @@ -110,7 +125,7 @@ void main () { of: find.byType(ZulipAppBar), matching: find.text('Channels'))).findsOne(); - await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.tap(find.byIcon(ZulipIcons.two_person)); await tester.pump(); check(find.descendant( of: find.byType(ZulipAppBar), @@ -118,67 +133,110 @@ void main () { }); testWidgets('combined feed', (tester) async { - final pushedRoutes = >[]; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - await prepare(tester, navigatorObserver: testNavObserver); + await prepare(tester); pushedRoutes.clear(); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: []).toJson()); await tester.tap(find.byIcon(ZulipIcons.message_feed)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); check(pushedRoutes).single.isA().page .isA() .initNarrow.equals(const CombinedFeedNarrow()); + await tester.pump(Duration.zero); // message-list fetch }); }); group('menu', () { final designVariables = DesignVariables.light; - final inboxMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.inbox)); - final channelsMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.hash_italic)); - final combinedFeedMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.message_feed)); - - Future tapOpenMenu(WidgetTester tester) async { + final inboxMenuIconFinder = find.byIcon(ZulipIcons.inbox); + final channelsMenuIconFinder = find.byIcon(ZulipIcons.hash_italic); + final combinedFeedMenuIconFinder = find.byIcon(ZulipIcons.message_feed); + + Future tapOpenMenuAndAwait(WidgetTester tester) async { + final topRouteBeforePress = topRoute; await tester.tap(find.byIcon(ZulipIcons.menu)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final topRouteAfterPress = topRoute; + check(topRouteAfterPress).isA>(); + await tester.pump((topRouteAfterPress as ModalBottomSheetRoute).transitionDuration); + + // This was the only change during the interaction. + check(topRouteBeforePress).identicalTo(previousTopRoute); + + // We got to the sheet by pushing, not popping or something else. + check(pushedRoutes.last).identicalTo(topRouteAfterPress); + check(find.byType(BottomSheet)).findsOne(); } + /// Taps the [buttonFinder] button and awaits the bottom sheet's exit. + /// + /// Includes a check that the bottom sheet is gone. + /// Also awaits the transition to a new pushed route, if one is pushed. + /// + /// [buttonFinder] will be run only in the bottom sheet's subtree; + /// it doesn't need its own `find.descendant` logic. + Future tapButtonAndAwaitTransition(WidgetTester tester, Finder buttonFinder) async { + final topRouteBeforePress = topRoute; + check(topRouteBeforePress).isA>(); + final numPushedRoutesBeforePress = pushedRoutes.length; + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: buttonFinder)); + await tester.pump(Duration.zero); + + final newPushedRoute = pushedRoutes.skip(numPushedRoutesBeforePress) + .singleOrNull; + + final sheetPopDuration = (topRouteBeforePress as ModalBottomSheetRoute) + .reverseTransitionDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(sheetPopDuration + Duration(milliseconds: 1)); + check(find.byType(BottomSheet)).findsNothing(); + + if (newPushedRoute != null) { + final pushDuration = (newPushedRoute as TransitionRoute).transitionDuration; + if (pushDuration > sheetPopDuration) { + await tester.pump(pushDuration - sheetPopDuration); + } + } + + // We dismissed the sheet by popping, not pushing or replacing. + check(topRouteBeforePress as Route?) + ..not((it) => it.identicalTo(topRoute)) + ..identicalTo(lastPoppedRoute); + } + void checkIconSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.iconSelected); } void checkIconNotSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.icon); } testWidgets('navigation states reflect on navigation bar menu buttons', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, find.text('Cancel')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); @@ -186,85 +244,74 @@ void main () { testWidgets('navigation bar menu buttons control navigation states', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsOne(); check(find.byType(SubscriptionListPageBody)).findsNothing(); - await tester.tap(channelsMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsNothing(); check(find.byType(SubscriptionListPageBody)).findsOne(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); testWidgets('navigation bar menu buttons dismiss the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(channelsMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); testWidgets('cancel button dismisses the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.text('Cancel')); }); testWidgets('menu buttons dismiss the menu', (tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final connection = store.connection as FakeApiConnection; await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [eg.streamMessage()]).toJson()); - await tester.tap(combinedFeedMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, combinedFeedMenuIconFinder); // When we go back to the home page, the menu sheet should be gone. + final topBeforePop = topRoute; + check(topBeforePop).isNotNull().isA() + .page.isA().initNarrow.equals(CombinedFeedNarrow()); (await ZulipApp.navigator).pop(); - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump((topBeforePop as TransitionRoute).reverseTransitionDuration); + check(find.byType(BottomSheet)).findsNothing(); }); testWidgets('_MyProfileButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.text('My profile')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.text('My profile')); check(find.byType(ProfilePage)).findsOne(); check(find.text(eg.selfUser.fullName)).findsAny(); }); testWidgets('_AboutZulipButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.byIcon(ZulipIcons.info)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.byIcon(ZulipIcons.info)); check(find.byType(AboutZulipPage)).findsOne(); }); }); @@ -282,24 +329,38 @@ void main () { Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); await tester.pump(Duration.zero); // wait for the loading page checkOnLoadingPage(); } - Future tapChooseAccount(WidgetTester tester) async { + Future tapTryAnotherAccount(WidgetTester tester) async { + final numPushedRoutesBefore = pushedRoutes.length; await tester.tap(find.text('Try another account')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final pushedRoute = pushedRoutes.skip(numPushedRoutesBefore).single; + check(pushedRoute).isA().page.isA(); + await tester.pump((pushedRoute as TransitionRoute).transitionDuration); checkOnChooseAccountPage(); } Future chooseAccountWithEmail(WidgetTester tester, String email) async { + lastPoppedRoute = null; await tester.tap(find.text(email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(topRoute).isA().page.isA(); + check(lastPoppedRoute).isA().page.isA(); + final popDuration = (lastPoppedRoute as TransitionRoute).reverseTransitionDuration; + final pushDuration = (topRoute as TransitionRoute).transitionDuration; + final animationDuration = popDuration > pushDuration ? popDuration : pushDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(animationDuration + Duration(milliseconds: 1)); checkOnLoadingPage(); } @@ -330,11 +391,16 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnLoadingPage(); await tester.pump(loadPerAccountDuration); @@ -345,7 +411,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2; await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -363,7 +429,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // While still loading, choose a different account. await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -385,7 +451,7 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the first account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, eg.otherAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -396,7 +462,7 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the second account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, thirdAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -413,15 +479,20 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); checkOnChooseAccountPage(); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); @@ -429,16 +500,21 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); checkOnChooseAccountPage(); // Choosing the already loaded account should result in no loading page. + lastPoppedRoute = null; await tester.tap(find.text(eg.selfAccount.email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); // No additional wait for loadPerAccount. checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 9fa5de1bf3..4d24e5d831 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -196,6 +196,7 @@ void main() { group('InboxPage', () { testWidgets('page builds; empty', (tester) async { await setupPage(tester, unreadMessages: []); + check(find.textContaining('There are no unread messages in your inbox.')).findsOne(); }); // TODO more checks: ordering, etc. @@ -314,7 +315,7 @@ void main() { subscriptions: [(eg.subscription(channel))], unreadMessages: [eg.streamMessage(stream: channel, topic: '')]); check(find.text(eg.defaultRealmEmptyTopicDisplayName)).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); group('topic visibility', () { final channel = eg.stream(); diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 31a4132a7c..7ccde30032 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:checks/checks.dart'; import 'package:clock/clock.dart'; @@ -8,14 +9,21 @@ import 'package:flutter/material.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:video_player/video_player.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/lightbox.dart'; +import 'package:zulip/widgets/message_list.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/content_test.dart'; +import '../model/test_store.dart'; import '../test_images.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -196,16 +204,132 @@ class FakeVideoPlayerPlatform extends Fake void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; + + late PerAccountStore store; + + group('LightboxHero', () { + late PerAccountStore store; + late FakeApiConnection connection; + + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel, + topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); + + // From ContentExample.imageSingle. + final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; + final imageSrcUrl = Uri.parse(imageSrcUrlStr); + final imageFinder = find.byWidgetPredicate( + (widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); + + Future setupMessageListPage(WidgetTester tester) async { + addTearDown(testBinding.reset); + final subscription = eg.subscription(channel); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + streams: [channel], subscriptions: [subscription])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + await store.addUser(eg.selfUser); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); + await tester.pumpAndSettle(); + } + + testWidgets('Hero animation occurs smoothly when opening lightbox from message list', (tester) async { + double dist(Rect a, Rect b) => + sqrt(pow(a.top - b.top, 2) + pow(a.left - b.left, 2)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final initialImagePosition = tester.getRect(imageFinder); + await tester.tap(imageFinder); + await tester.pump(); + // pump to start hero animation + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + final animatedPositions = []; + for (int i = 1; i <= steps; i++) { + await tester.pump(stepDuration); + animatedPositions.add(tester.getRect(imageFinder)); + } + + final totalDistance = dist(initialImagePosition, animatedPositions.last); + Rect previousPosition = initialImagePosition; + double maxStepDistance = 0.0; + for (final position in animatedPositions) { + final stepDistance = dist(previousPosition, position); + maxStepDistance = max(maxStepDistance, stepDistance); + check(position).not((pos) => pos.equals(previousPosition)); + + previousPosition = position; + } + check(maxStepDistance).isLessThan(0.03 * totalDistance); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { + Rect getElementRect(Element element) => + tester.getRect(find.byElementPredicate((e) => e == element)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final firstElement = tester.element(imageFinder); + final firstImagePosition = getElementRect(firstElement); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('test topic'))); + await tester.pumpAndSettle(); + + final secondElement = tester.element(imageFinder); + final secondImagePosition = getElementRect(secondElement); + + await tester.tap(find.byType(BackButton)); + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + for (int i = 0; i < steps; i++) { + await tester.pump(stepDuration); + check(tester.elementList(imageFinder)) + .unorderedEquals([firstElement, secondElement]); + check(getElementRect(firstElement)).equals(firstImagePosition); + check(getElementRect(secondElement)).equals(secondImagePosition); + } + + debugNetworkImageHttpClientProvider = null; + }); + }); group('_ImageLightboxPage', () { final src = Uri.parse('https://chat.example/lightbox-image.png'); Future setupPage(WidgetTester tester, { Message? message, + List? users, required Uri? thumbnailUrl, }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + if (users != null) { + await store.addUsers(users); + } // ZulipApp instead of TestZulipApp because we need the navigator to push // the lightbox route. The lightbox page works together with the route; @@ -216,6 +340,7 @@ void main() { unawaited(navigator.push(getImageLightboxRoute( accountId: eg.selfAccount.id, message: message ?? eg.streamMessage(), + messageImageContext: navigator.context, src: src, thumbnailUrl: thumbnailUrl, originalHeight: null, @@ -236,20 +361,41 @@ void main() { debugNetworkImageHttpClientProvider = null; }); - testWidgets('app bar shows sender name and date', (tester) async { - prepareBoringImageHttpClient(); - final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; - final message = eg.streamMessage(sender: eg.otherUser, timestamp: timestamp); - await setupPage(tester, message: message, thumbnailUrl: null); - - // We're looking for a RichText, in the app bar, with both the - // sender's name and the timestamp. + void checkAppBarNameAndDate(WidgetTester tester, String expectedName, String expectedDate) { final labelTextWidget = tester.widget( find.descendant(of: find.byType(AppBar).last, - matching: find.textContaining(findRichText: true, - eg.otherUser.fullName))); + matching: find.textContaining(findRichText: true, expectedName))); check(labelTextWidget.text.toPlainText()) - .contains('Jul 23, 2024 23:12:24'); + .contains(expectedDate); + } + + testWidgets('app bar shows sender name and date; updates when name changes', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Old name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]); + check(store.getUser(sender.userId)).isNotNull(); + + checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 23:12:24'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: sender.userId, fullName: 'New name')); + await tester.pump(); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 23:12:24'); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('app bar shows sender name and date; unknown sender', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Sender name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: []); + check(store.getUser(sender.userId)).isNull(); + + checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24'); debugNetworkImageHttpClientProvider = null; }); diff --git a/test/widgets/message_list_checks.dart b/test/widgets/message_list_checks.dart index 6ce43a2d43..0f736466f1 100644 --- a/test/widgets/message_list_checks.dart +++ b/test/widgets/message_list_checks.dart @@ -4,4 +4,5 @@ import 'package:zulip/widgets/message_list.dart'; extension MessageListPageChecks on Subject { Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); + Subject get initAnchorMessageId => has((x) => x.initAnchorMessageId, 'initAnchorMessageId'); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 047a036cb5..c6be56359e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; @@ -11,21 +12,27 @@ 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'; +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'; 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'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; 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; @@ -36,6 +43,7 @@ import '../flutter_checks.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'compose_box_checks.dart'; import 'content_checks.dart'; import 'dialog_checks.dart'; import 'message_list_checks.dart'; @@ -44,6 +52,7 @@ import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -53,8 +62,10 @@ void main() { bool foundOldest = true, int? messageCount, List? messages, + GetMessagesResult? fetchResult, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -77,12 +88,20 @@ void main() { // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); - assert((messageCount == null) != (messages == null)); - messages ??= List.generate(messageCount!, (index) { - return eg.streamMessage(sender: eg.selfUser); - }); - connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } + if (fetchResult != null) { + assert(foundOldest && messageCount == null && messages == null); + } else { + assert((messageCount == null) != (messages == null)); + messages ??= List.generate(messageCount!, (index) { + return eg.streamMessage(sender: eg.selfUser); + }); + fetchResult = eg.newestGetMessagesResult( + foundOldest: foundOldest, messages: messages); + } + connection.prepare(json: fetchResult.toJson()); await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, skipAssertAccountExists: skipAssertAccountExists, @@ -110,6 +129,9 @@ void main() { return findScrollView(tester).controller; } + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + group('MessageListPage', () { testWidgets('ancestorOf finds page state from message', (tester) async { await setupMessageListPage(tester, @@ -204,7 +226,37 @@ void main() { messageCount: 1); checkAppBarChannelTopic( channel.name, eg.defaultRealmEmptyTopicDisplayName); - }, skip: true); // null topic names soon to be enabled + }); + + 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 = >[]; @@ -226,6 +278,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'; @@ -241,6 +312,80 @@ 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(); + }); + + testWidgets('shows "Muted user" label for muted users in DM narrow', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + narrow: DmNarrow.withOtherUsers([1, 2, 3], selfUserId: 10), + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messageCount: 1, + ); + + check(find.text('DMs with Muted user, User 2, Muted user')).findsOne(); + }); + }); + + group('no-messages placeholder', () { + final findPlaceholder = find.byType(PageBodyEmptyContentPlaceholder); + + Finder findTextInPlaceholder(String text) => + find.descendant(of: findPlaceholder, matching: find.textContaining(text)); + + testWidgets('Combined feed', (tester) async { + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), messages: []); + check(findTextInPlaceholder('There are no messages here.')).findsOne(); + }); + + testWidgets('Search, empty keyword', (tester) async { + await setupMessageListPage(tester, narrow: KeywordSearchNarrow(''), messages: []); + check(findTextInPlaceholder('No search results.')).findsOne(); + }); + + testWidgets('Search, non-empty keyword', (tester) async { + await setupMessageListPage(tester, narrow: KeywordSearchNarrow('hello'), messages: []); + check(findTextInPlaceholder('No search results.')).findsOne(); + }); + + testWidgets('when `messages` empty but `outboxMessages` not empty, show outboxes, not placeholder', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(findPlaceholder).findsOne(); + + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await tester.enterText(contentInputFinder, 'asdfjkl;'); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(kLocalEchoDebounceDuration); + + check(findPlaceholder).findsNothing(); + check(find.text('asdfjkl;')).findsOne(); + }); }); group('presents message content appropriately', () { @@ -280,20 +425,25 @@ 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', () { + // TODO(#1571): test effect of visitFirstUnread setting + // TODO(#1569): test effect of initAnchorMessageId + // TODO(#1569): test that after jumpToEnd, then new store causing new fetch, + // new post-jump anchor prevails over initAnchorMessageId + group('topic permalink', () { final someStream = eg.stream(); const someTopic = 'some topic'; @@ -328,9 +478,10 @@ void main() { ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ 'narrow': jsonEncode(narrow.apiEncode()), - 'anchor': AnchorCode.newest.toJson(), + 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), - 'num_after': '0', + 'num_after': kMessageListFetchBatchSize.toString(), + 'allow_empty_topic_name': 'true', }); }); @@ -360,15 +511,20 @@ void main() { ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ 'narrow': jsonEncode(narrow.apiEncode()), - 'anchor': AnchorCode.newest.toJson(), + 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), - 'num_after': '0', + 'num_after': kMessageListFetchBatchSize.toString(), + 'allow_empty_topic_name': 'true', }); }); }); }); group('fetch older messages on scroll', () { + // TODO(#1569): test fetch newer messages on scroll, too; + // in particular test it happens even when near top as well as bottom + // (because may have haveOldest true but haveNewest false) + int? itemCount(WidgetTester tester) => findScrollView(tester).semanticChildCount; @@ -458,23 +614,61 @@ void main() { // [MessageListScrollView], in scrolling_test.dart . testWidgets('sticks to end upon new message', (tester) async { - await setupMessageListPage(tester, - messages: List.generate(10, (_) => eg.streamMessage(content: '

a

'))); + await setupMessageListPage(tester, messages: List.generate(10, + (i) => eg.streamMessage(content: '

message $i

'))); final controller = findMessageListScrollController(tester)!; + final findMiddleMessage = find.text('message 5'); - // Starts at end, and with room to scroll up. - check(controller.position) - ..extentAfter.equals(0) - ..extentBefore.isGreaterThan(0); - final oldPosition = controller.position.pixels; + // Started out scrolled to the bottom. + check(controller.position).extentAfter.equals(0); + final scrollPixels = controller.position.pixels; + + // Note the position of some mid-screen message. + final messageRect = tester.getRect(findMiddleMessage); + check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600); - // On new message, position remains at end… + // When a new message arrives, the existing message moves up… await store.addMessage(eg.streamMessage(content: '

a

b

')); await tester.pump(); + check(tester.getRect(findMiddleMessage)) + ..top.isLessThan(messageRect.top) + ..height.isCloseTo(messageRect.height, Tolerance().distance); + // … because the position remains at the end… check(controller.position) ..extentAfter.equals(0) // … even though that means a bigger number now. - ..pixels.isGreaterThan(oldPosition); + ..pixels.isGreaterThan(scrollPixels); + }); + + testWidgets('preserves visible messages upon new message, when not at end', (tester) async { + await setupMessageListPage(tester, messages: List.generate(10, + (i) => eg.streamMessage(content: '

message $i

'))); + final controller = findMessageListScrollController(tester)!; + final findMiddleMessage = find.text('message 5'); + + // Started at bottom. Scroll up a bit. + check(controller.position).extentAfter.equals(0); + controller.position.jumpTo(controller.position.pixels - 100); + await tester.pump(); + check(controller.position).extentAfter.equals(100); + final scrollPixels = controller.position.pixels; + + // Note the position of some mid-screen message. + final messageRect = tester.getRect(findMiddleMessage); + check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600); + + // When a new message arrives, the existing message doesn't shift… + await store.addMessage(eg.streamMessage(content: '

a

b

')); + await tester.pump(); + check(tester.getRect(findMiddleMessage)).equals(messageRect); + // … because the scroll position value remained the same… + check(controller.position) + ..pixels.equals(scrollPixels) + // … even though there's now more content off screen below. + // (This last check relies on the fact that the old extentAfter is small, + // less than cacheExtent, so that the new content is only barely offscreen, + // it gets built, and the new extentAfter reflects it.) + ..extentAfter.isGreaterThan(100); }); }); @@ -542,6 +736,8 @@ void main() { check(isButtonVisible(tester)).equals(false); }); + // TODO(#1569): test choice of jumpToEnd vs. scrollToEnd + testWidgets('scrolls at reasonable, constant speed', (tester) async { const maxSpeed = 8000.0; const distance = 40000.0; @@ -578,6 +774,63 @@ void main() { }); }); + // TODO test markers at start of list (`_buildStartCap`) + + group('markers at end of list', () { + final findLoadingIndicator = find.byType(CircularProgressIndicator); + + testWidgets('spacer when have newest', (tester) async { + final messages = List.generate(10, + (i) => eg.streamMessage(content: '

message $i

')); + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), + fetchResult: eg.nearGetMessagesResult(anchor: messages.last.id, + foundOldest: true, foundNewest: true, messages: messages)); + check(findMessageListScrollController(tester)!.position) + .extentAfter.equals(0); + + // There's no loading indicator. + check(findLoadingIndicator).findsNothing(); + // The last message is spaced above the bottom of the viewport. + check(tester.getRect(find.text('message 9'))) + .bottom..isGreaterThan(400)..isLessThan(570); + }); + + testWidgets('loading indicator displaces spacer etc.', (tester) async { + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), + skipPumpAndSettle: true, + // TODO(#1569) fix realism of this data: foundNewest false should mean + // some messages found after anchor (and then we might need to scroll + // to cause fetching newer messages). + fetchResult: eg.nearGetMessagesResult(anchor: 1000, + foundOldest: true, foundNewest: false, + messages: List.generate(10, + (i) => eg.streamMessage(id: 100 + i, content: '

message $i

')))); + await tester.pump(); + + // The message list will immediately start fetching newer messages. + connection.prepare(json: eg.newerGetMessagesResult( + anchor: 109, foundNewest: true, messages: List.generate(100, + (i) => eg.streamMessage(id: 110 + i))).toJson()); + await tester.pump(Duration(milliseconds: 10)); + await tester.pump(); + + // There's a loading indicator. + check(findLoadingIndicator).findsOne(); + // It's at the bottom. + check(findMessageListScrollController(tester)!.position) + .extentAfter.equals(0); + final loadingIndicatorRect = tester.getRect(findLoadingIndicator); + check(loadingIndicatorRect).bottom.isGreaterThan(575); + // The last message is shortly above it; no spacer or anything else. + check(tester.getRect(find.text('message 9'))) + .bottom.isGreaterThan(loadingIndicatorRect.top - 36); // TODO(#1569) where's this space going? + await tester.pumpAndSettle(); + }); + + // TODO(#1569) test no typing status or mark-read button when not haveNewest + // (even without loading indicator) + }); + group('TypingStatusWidget', () { final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser]; final finder = find.descendant( @@ -901,7 +1154,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') @@ -910,8 +1164,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 { @@ -1015,7 +1273,7 @@ void main() { await tester.pump(); check(findInMessageList('stream name')).single; check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show general chat for empty topics without channel name', (tester) async { await setupMessageListPage(tester, @@ -1024,7 +1282,7 @@ void main() { await tester.pump(); check(findInMessageList('stream name')).isEmpty(); check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show topic visibility icon when followed', (tester) async { await setupMessageListPage(tester, @@ -1146,6 +1404,33 @@ void main() { tester.widget(find.text('new stream name')); }); + testWidgets('navigates to ChannelNarrow on tapping channel in CombinedFeedNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final subscription = eg.subscription(channel); + final message = eg.streamMessage(stream: channel, topic: 'topic name'); + await setupMessageListPage(tester, + narrow: CombinedFeedNarrow(), + subscriptions: [subscription], + messages: [message], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text(channel.name))); + await tester.pump(); + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(ChannelNarrow(channel.streamId)); + await tester.pumpAndSettle(); + }); + testWidgets('navigates to TopicNarrow on tapping topic in ChannelNarrow', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -1227,6 +1512,21 @@ void main() { "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); }); + testWidgets('show "Muted user" label for muted users', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messages: [eg.dmMessage(from: eg.selfUser, to: [user1, user2, user3])] + ); + + check(find.text('You and Muted user, Muted user, User 2')).findsOne(); + }); + testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await setupMessageListPage(tester, messages: [ @@ -1236,7 +1536,7 @@ void main() { final textSpan = tester.renderObject(find.text( zulipLocalizations.messageListGroupYouAndOthers( zulipLocalizations.unknownUserName))).text; - final icon = tester.widget(find.byIcon(ZulipIcons.user)); + final icon = tester.widget(find.byIcon(ZulipIcons.two_person)); check(textSpan).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); }); }); @@ -1324,6 +1624,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); @@ -1406,6 +1730,311 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('Muted sender', () { + void checkMessage(Message message, {required bool expectIsMuted}) { + final mutedLabel = 'Muted user'; + final mutedLabelFinder = find.widgetWithText(MessageWithPossibleSender, + mutedLabel); + + final avatarFinder = find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == message.senderId); + final mutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byIcon(ZulipIcons.person)); + final nonmutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byType(RealmContentNetworkImage)); + + final senderName = store.senderDisplayName(message, replaceIfMuted: false); + assert(senderName != mutedLabel); + final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, + senderName); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(mutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(nonmutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(contentFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + } + + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + testWidgets('muted appearance', (tester) async { + prepareBoringImageHttpClient(); + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('not-muted appearance', (tester) async { + prepareBoringImageHttpClient(); + await setupMessageListPage(tester, + users: [user], mutedUserIds: [], messages: [message]); + checkMessage(message, expectIsMuted: false); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('"Reveal message" button', (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + await tester.tap(find.text('Reveal message')); + await tester.pump(); + checkMessage(message, expectIsMuted: false); + + debugNetworkImageHttpClientProvider = null; + }); + }); + + group('Opens conversation on tap?', () { + // (copied from test/widgets/content_test.dart) + Future tapText(WidgetTester tester, Finder textFinder) async { + final height = tester.getSize(textFinder).height; + final target = tester.getTopLeft(textFinder) + .translate(height/4, height/2); // aim for middle of first letter + await tester.tapAt(target); + } + + final subscription = eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId)); + final topic = 'some topic'; + + void doTest(Narrow narrow, { + required bool expected, + required Message Function() mkMessage, + }) { + testWidgets('${expected ? 'yes' : 'no'}, if in $narrow', (tester) async { + final message = mkMessage(); + + Route? lastPushedRoute; + final navObserver = TestNavigatorObserver() + ..onPushed = ((route, prevRoute) => lastPushedRoute = route); + + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + subscriptions: [subscription], + navObservers: [navObserver] + ); + lastPushedRoute = null; + + // Tapping interactive content still works. + await store.handleEvent(eg.updateMessageEditEvent(message, + renderedContent: '

link

')); + await tester.pump(); + await tapText(tester, find.text('link')); + await tester.pump(Duration.zero); + check(lastPushedRoute).isNull(); + final launchUrlCalls = testBinding.takeLaunchUrlCalls(); + check(launchUrlCalls.single.url).equals(Uri.parse('https://example/')); + + // Tapping non-interactive content opens the conversation (if expected). + await store.handleEvent(eg.updateMessageEditEvent(message, + renderedContent: '

plain content

')); + await tester.pump(); + await tapText(tester, find.text('plain content')); + if (expected) { + final expectedNarrow = SendableNarrow.ofMessage(message, selfUserId: store.selfUserId); + + check(lastPushedRoute).isNotNull().isA() + .page.isA() + ..initNarrow.equals(expectedNarrow) + ..initAnchorMessageId.equals(message.id); + } else { + check(lastPushedRoute).isNull(); + } + + // TODO test tapping whitespace in message + }); + } + + doTest(expected: false, CombinedFeedNarrow(), + mkMessage: () => eg.streamMessage()); + doTest(expected: false, ChannelNarrow(subscription.streamId), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, TopicNarrow(subscription.streamId, eg.t(topic)), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, DmNarrow.withUsers([], selfUserId: eg.selfUser.userId), + mkMessage: () => eg.streamMessage(stream: subscription, topic: topic)); + doTest(expected: true, StarredMessagesNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.starred])); + doTest(expected: true, MentionsNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.mentioned])); + }); + }); + + group('OutboxMessageWithPossibleSender', () { + final stream = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + const content = 'outbox message content'; + + Finder outboxMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, content, skipOffstage: true); + + Finder messageNotSentFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.text('MESSAGE NOT SENT')).hitTestable(); + Finder loadingIndicatorFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.byType(LinearProgressIndicator)).hitTestable(); + + Future sendMessageAndSucceed(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(json: SendMessageResult(id: 1).toJson(), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future sendMessageAndFail(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(httpException: SocketException('error'), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future dismissErrorDialog(WidgetTester tester) async { + await tester.tap(find.byWidget( + checkErrorDialog(tester, expectedTitle: 'Message not sent'))); + await tester.pump(Duration(milliseconds: 250)); + } + + Future checkTapRestoreMessage(WidgetTester tester) async { + final state = tester.state(find.byType(ComposeBox)); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + check(messageNotSentFinder).findsOne(); + check(state).controller.content.text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(store.outboxMessages).isEmpty(); + check(outboxMessageFinder).findsNothing(); + check(state).controller.content.text.equals(content); + } + + Future checkTapNotRestoreMessage(WidgetTester tester) async { + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + + // the message should ignore the pointer event + await tester.tap(outboxMessageFinder, warnIfMissed: false); + await tester.pump(); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + } + + // State transitions are tested more thoroughly in + // test/model/message_test.dart . + + testWidgets('hidden -> waiting', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + + await sendMessageAndSucceed(tester); + check(outboxMessageFinder).findsNothing(); + + await tester.pump(kLocalEchoDebounceDuration); + check(outboxMessageFinder).findsOne(); + check(loadingIndicatorFinder).findsOne(); + // The outbox message is still in waiting state; + // tapping does not restore it. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + // Send a message and fail. Dismiss the error dialog as it pops up. + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tapping does nothing if compose box is not offered', (tester) async { + Route? lastPoppedRoute; + final navObserver = TestNavigatorObserver() + ..onPopped = (route, prevRoute) => lastPoppedRoute = route; + + final messages = [eg.streamMessage( + stream: stream, topic: topic, content: content)]; + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + streams: [stream], subscriptions: [eg.subscription(stream)], + navObservers: [navObserver], + messages: messages); + + // Navigate to a message list page in a topic narrow, + // which has a compose box. + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + await tester.tap(find.widgetWithText(RecipientHeader, topic)); + await tester.pump(); // handle tap + await tester.pump(); // wait for navigation + check(contentInputFinder).findsOne(); + + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + // Navigate back to the message list page without a compose box, + // where the failed to send message should be visible. + + await tester.pageBack(); + check(lastPoppedRoute) + .isA().page + .isA() + .initNarrow.equals(TopicNarrow(stream.streamId, eg.t(topic))); + await tester.pump(); // handle tap + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(contentInputFinder).findsNothing(); + check(messageNotSentFinder).findsOne(); + + // Tap the failed to send message. + // This should not remove it from the message list. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('waiting -> waitPeriodExpired, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + await sendMessageAndFail(tester, + delay: kSendMessageOfferRestoreWaitPeriod + const Duration(seconds: 1)); + await tester.pump(kSendMessageOfferRestoreWaitPeriod); + final localMessageId = store.outboxMessages.keys.single; + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + + // While `localMessageId` is no longer in store, there should be no error + // when a message event refers to it. + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + + // The [sendMessage] request fails; there is no outbox message affected. + await tester.pump(Duration(seconds: 1)); + check(messageNotSentFinder).findsNothing(); + }); }); group('Starred messages', () { @@ -1422,7 +2051,7 @@ void main() { }); }); - group('edit state label', () { + group('EDITED/MOVED label and edit-message error status', () { void checkMarkersCount({required int edited, required int moved}) { check(find.text('EDITED').evaluate()).length.equals(edited); check(find.text('MOVED').evaluate()).length.equals(moved); @@ -1453,6 +2082,81 @@ void main() { await tester.pump(); checkMarkersCount(edited: 2, moved: 0); }); + + void checkEditInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsOne(); + check(find.byType(LinearProgressIndicator)).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + void checkEditNotInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsNothing(); + check(find.byType(LinearProgressIndicator)).findsNothing(); + check(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))).findsNothing(); + } + + void checkEditFailed(WidgetTester tester) { + check(find.text('EDIT NOT SAVED')).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + testWidgets('successful edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(json: UpdateMessageResult().toJson()); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await store.handleEvent(eg.updateMessageEditEvent(message)); + await tester.pump(); + checkEditNotInProgress(tester); + }); + + testWidgets('failed edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await tester.pump(Duration(seconds: 1)); + checkEditFailed(tester); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson(), delay: Duration(milliseconds: 500)); + await tester.tap(find.byType(MessageContent)); + // We don't clear out the failed attempt, with the intended new content… + checkEditFailed(tester); + await tester.pump(Duration(milliseconds: 500)); + // …until we have the current content, from a successful message fetch, + // for prevContentSha256. + checkEditNotInProgress(tester); + + final state = MessageListPage.ancestorOf(tester.element(find.byType(MessageContent))); + check(state.composeBoxState).isNotNull().controller + .isA() + .content.value.text.equals('bar'); + }); }); group('_UnreadMarker animations', () { @@ -1515,15 +2219,8 @@ void main() { // as the number of items changes in MessageList. See // `findChildIndexCallback` passed into [SliverStickyHeaderList] // at [_MessageListState._buildListView]. - - // TODO(#82): Cut paddingMessage. It's there to paper over a glitch: - // the _UnreadMarker animation *does* get interrupted in the case where - // the message gets pushed from one sliver to the other. See: - // https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779 - // That case will no longer exist when #82 is complete. final message = eg.streamMessage(flags: []); - final paddingMessage = eg.streamMessage(); - await setupMessageListPage(tester, messages: [message, paddingMessage]); + await setupMessageListPage(tester, messages: [message]); check(getAnimation(tester, message.id)) ..value.equals(1.0) ..status.equals(AnimationStatus.dismissed); @@ -1547,11 +2244,10 @@ void main() { ..status.equals(AnimationStatus.forward); // introduce new message - check(find.byType(MessageItem)).findsExactly(2); final newMessage = eg.streamMessage(flags:[MessageFlag.read]); await store.addMessage(newMessage); await tester.pump(); // process handleEvent - check(find.byType(MessageItem)).findsExactly(3); + check(find.byType(MessageItem)).findsExactly(2); check(getAnimation(tester, message.id)) ..value.isGreaterThan(0.0) ..value.isLessThan(1.0) diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart new file mode 100644 index 0000000000..65d92f72a2 --- /dev/null +++ b/test/widgets/new_dm_sheet_test.dart @@ -0,0 +1,328 @@ +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:zulip/api/model/model.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'test_app.dart'; + +Future setupSheet(WidgetTester tester, { + required List users, +}) async { + addTearDown(testBinding.reset); + + Route? lastPushedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => lastPushedRoute = route; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + await tester.pumpWidget(TestZulipApp( + navigatorObservers: [testNavObserver], + accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(ZulipIcons.two_person)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isNotNull().isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + final findComposeButton = find.widgetWithText(GestureDetector, 'Compose'); + void checkComposeButtonEnabled(WidgetTester tester, bool expected) { + final button = tester.widget(findComposeButton); + if (expected) { + check(button.onTap).isNotNull(); + } else { + check(button.onTap).isNull(); + } + } + + Finder findUserTile(User user) => + find.widgetWithText(InkWell, user.fullName).first; + + Finder findUserChip(User user) { + final findAvatar = find.byWidgetPredicate((widget) => + widget is Avatar + && widget.userId == user.userId + && widget.size == 22); + + return find.ancestor(of: findAvatar, matching: find.byType(GestureDetector)); + } + + testWidgets('shows header with correct buttons', (tester) async { + await setupSheet(tester, users: []); + + check(find.descendant( + of: find.byType(NewDmPicker), + matching: find.text('New DM'))).findsOne(); + check(find.text('Cancel')).findsOne(); + check(findComposeButton).findsOne(); + + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('search field has focus when sheet opens', (tester) async { + await setupSheet(tester, users: []); + + void checkHasFocus() { + // Some element is focused… + final focusedElement = tester.binding.focusManager.primaryFocus?.context; + check(focusedElement).isNotNull(); + + // …it's a TextField. Specifically, the search input. + final focusedTextFieldWidget = focusedElement! + .findAncestorWidgetOfExactType(); + check(focusedTextFieldWidget).isNotNull() + .decoration.isNotNull() + .hintText.equals('Add one or more users'); + } + + checkHasFocus(); // It's focused initially. + await tester.pump(Duration(seconds: 1)); + checkHasFocus(); // Something else doesn't come along and steal the focus. + }); + + group('user filtering', () { + final testUsers = [ + eg.user(fullName: 'Alice Anderson'), + eg.user(fullName: 'Bob Brown'), + eg.user(fullName: 'Charlie Carter'), + ]; + + testWidgets('shows all users initially', (tester) async { + await setupSheet(tester, users: testUsers); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsOne(); + check(find.text('Charlie Carter')).findsOne(); + }); + + testWidgets('shows filtered users based on search', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + // TODO test sorting by recent-DMs + // TODO test that scroll position resets on query change + + testWidgets('search is case-insensitive', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + }); + + testWidgets('partial name and last name search handling', (tester) async { + await setupSheet(tester, users: testUsers); + + await tester.enterText(find.byType(TextField), 'Ali'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'Anderson'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'son'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + testWidgets('shows empty state when no users match', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Zebra'); + await tester.pump(); + check(find.text('No users found')).findsOne(); + check(find.text('Alice Anderson')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + }); + + testWidgets('search text clears when user is selected', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [user]); + + await tester.enterText(find.byType(TextField), 'Test'); + await tester.pump(); + final textField = tester.widget(find.byType(TextField)); + check(textField.controller!.text).equals('Test'); + + await tester.tap(findUserTile(user)); + await tester.pump(); + check(textField.controller!.text).isEmpty(); + }); + }); + + group('user selection', () { + void checkUserSelected(WidgetTester tester, User user, bool expected) { + final icon = tester.widget(find.descendant( + of: findUserTile(user), + matching: find.byType(Icon))); + + if (expected) { + check(findUserChip(user)).findsOne(); + check(icon).icon.equals(ZulipIcons.check_circle_checked); + } else { + check(findUserChip(user)).findsNothing(); + check(icon).icon.equals(ZulipIcons.check_circle_unchecked); + } + } + + testWidgets('tapping user chip deselects the user', (tester) async { + await setupSheet(tester, users: [eg.selfUser, eg.otherUser, eg.thirdUser]); + + await tester.tap(findUserTile(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, true); + await tester.tap(findUserChip(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, false); + }); + + testWidgets('selecting and deselecting a user', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [eg.selfUser, user]); + + checkUserSelected(tester, user, false); + checkUserSelected(tester, eg.selfUser, false); + checkComposeButtonEnabled(tester, false); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, true); + checkComposeButtonEnabled(tester, true); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, false); + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('other user selection deselects self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + await tester.tap(findUserTile(eg.selfUser)); + await tester.pump(); + checkUserSelected(tester, eg.selfUser, true); + check(find.text(eg.selfUser.fullName)).findsExactly(2); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + checkUserSelected(tester, otherUser, true); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('other user selection hides self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + check(find.text(eg.selfUser.fullName)).findsOne(); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('can select multiple users', (tester) async { + final user1 = eg.user(fullName: 'Test User 1'); + final user2 = eg.user(fullName: 'Test User 2'); + await setupSheet(tester, users: [user1, user2]); + + await tester.tap(findUserTile(user1)); + await tester.pump(); + await tester.tap(findUserTile(user2)); + await tester.pump(); + checkUserSelected(tester, user1, true); + checkUserSelected(tester, user2, true); + }); + }); + + group('navigation to DM Narrow', () { + Future runAndCheck(WidgetTester tester, { + required List users, + required String expectedAppBarTitle, + }) async { + await setupSheet(tester, users: users); + + final context = tester.element(find.byType(NewDmPicker)); + final store = PerAccountStoreWidget.of(context); + final connection = store.connection as FakeApiConnection; + + connection.prepare( + json: eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + for (final user in users) { + await tester.tap(findUserTile(user)); + await tester.pump(); + } + await tester.tap(findComposeButton); + await tester.pumpAndSettle(); + check(find.widgetWithText(ZulipAppBar, expectedAppBarTitle)).findsOne(); + + check(find.byType(ComposeBox)).findsOne(); + } + + testWidgets('navigates to self DM', (tester) async { + await runAndCheck( + tester, + users: [eg.selfUser], + expectedAppBarTitle: 'DMs with yourself'); + }); + + testWidgets('navigates to 1:1 DM', (tester) async { + final user = eg.user(fullName: 'Test User'); + await runAndCheck( + tester, + users: [user], + expectedAppBarTitle: 'DMs with Test User'); + }); + + testWidgets('navigates to group DM', (tester) async { + final users = [ + eg.user(fullName: 'User 1'), + eg.user(fullName: 'User 2'), + eg.user(fullName: 'User 3'), + ]; + await runAndCheck( + tester, + users: users, + expectedAppBarTitle: 'DMs with User 1, User 2, User 3'); + }); + }); +} diff --git a/test/widgets/poll_test.dart b/test/widgets/poll_test.dart index a6bd74c77e..8e3d66c3bb 100644 --- a/test/widgets/poll_test.dart +++ b/test/widgets/poll_test.dart @@ -28,12 +28,16 @@ void main() { WidgetTester tester, SubmessageData? submessageContent, { Iterable? users, + List? mutedUserIds, Iterable<(User, int)> voterIdxPairs = const [], }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; message = eg.streamMessage( @@ -96,6 +100,18 @@ void main() { check(findTextAtRow('100', index: 0)).findsOne(); }); + testWidgets('muted voters', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await preparePollWidget(tester, pollWidgetData, + users: [user1, user2], + mutedUserIds: [user2.userId], + voterIdxPairs: [(user1, 0), (user2, 0), (user2, 1)]); + + check(findTextAtRow('(User 1, Muted user)', index: 0)).findsOne(); + check(findTextAtRow('(Muted user)', index: 1)).findsOne(); + }); + testWidgets('show unknown voter', (tester) async { await preparePollWidget(tester, pollWidgetData, users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 30f6433528..ac461fe73b 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,11 +1,14 @@ 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:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; @@ -13,15 +16,19 @@ import 'package:zulip/widgets/profile.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_images.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'profile_page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, NavigatorObserver? navigatorObserver, @@ -32,12 +39,15 @@ Future setupPage(WidgetTester tester, { customProfileFields: customProfileFields, realmDefaultExternalAccounts: realmDefaultExternalAccounts); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); if (users != null) { await store.addUsers(users); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -237,6 +247,43 @@ void main() { check(textFinder.evaluate()).length.equals(1); }); + testWidgets('page builds; user field with muted user', (tester) async { + prepareBoringImageHttpClient(); + + Finder avatarFinder(int userId) => find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == userId); + Finder mutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byIcon(ZulipIcons.person)); + Finder nonmutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byType(RealmContentNetworkImage)); + + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2', avatarUrl: '/foo.png'), + eg.user(userId: 3, fullName: 'test user3', avatarUrl: '/bar.png'), + ]; + + await setupPage(tester, + users: users, + mutedUserIds: [2], + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)]); + + check(find.text('Muted user')).findsOne(); + check(mutedAvatarFinder(2)).findsOne(); + check(nonmutedAvatarFinder(2)).findsNothing(); + + check(find.text('test user3')).findsOne(); + check(mutedAvatarFinder(3)).findsNothing(); + check(nonmutedAvatarFinder(3)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + testWidgets('page builds; dm links to correct narrow', (tester) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 44322ccea1..e543658d55 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; @@ -9,6 +10,7 @@ import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/recent_dm_conversations.dart'; @@ -25,6 +27,7 @@ import 'test_app.dart'; Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + List? mutedUserIds, NavigatorObserver? navigatorObserver, String? newNameForSelfUser, }) async { @@ -37,6 +40,9 @@ Future setupPage(WidgetTester tester, { for (final user in users) { await store.addUser(user); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await store.addMessages(dmMessages); @@ -56,7 +62,7 @@ Future setupPage(WidgetTester tester, { // Switch to direct messages tab. await tester.tap(find.descendant( of: find.byType(Center), - matching: find.byIcon(ZulipIcons.user))); + matching: find.byIcon(ZulipIcons.two_person))); await tester.pump(); } @@ -68,6 +74,12 @@ void main() { (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, ); + testWidgets('appearance when empty', (tester) async { + await setupPage(tester, users: [], dmMessages: []); + check(find.text('You have no direct messages yet! Why not start the conversation?')) + .findsOne(); + }); + testWidgets('page builds; conversations appear in order', (tester) async { final user1 = eg.user(userId: 1); final user2 = eg.user(userId: 2); @@ -106,6 +118,32 @@ void main() { await tester.pumpAndSettle(); check(tester.any(oldestConversationFinder)).isTrue(); // onscreen }); + + testWidgets('opens new DM sheet on New DM button tap', (tester) async { + Route? lastPushedRoute; + Route? lastPoppedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + + await setupPage(tester, navigatorObserver: testNavObserver, + users: [], dmMessages: []); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + check(find.byType(NewDmPicker)).findsOne(); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + check(lastPoppedRoute).isA>(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); + check(find.byType(NewDmPicker)).findsNothing(); + }); }); group('RecentDmConversationsItem', () { @@ -204,13 +242,27 @@ void main() { }); group('1:1', () { - testWidgets('has right title/avatar', (tester) async { - final user = eg.user(userId: 1); - final message = eg.dmMessage(from: eg.selfUser, to: [user]); - await setupPage(tester, users: [user], dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, user.fullName); + group('has right title/avatar', () { + testWidgets('non-muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, user.fullName); + }); + + testWidgets('muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [user], + mutedUserIds: [user.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user'); + }); }); testWidgets('no error when user somehow missing from user store', (tester) async { @@ -258,15 +310,45 @@ void main() { return result; } - testWidgets('has right title/avatar', (tester) async { - final users = usersList(2); - final user0 = users[0]; - final user1 = users[1]; - final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); - await setupPage(tester, users: users, dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + group('has right title/avatar', () { + testWidgets('no users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, users: users, dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + }); + + testWidgets('some users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, ${user1.fullName}'); + }); + + testWidgets('all users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId, user1.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, Muted user'); + }); }); testWidgets('no error when one user somehow missing from user store', (tester) async { diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 46df165ecc..96fd62feeb 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -127,6 +127,8 @@ void main() { }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); + // TODO(#1571): test visitFirstUnread setting UI + // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings // Or maybe not; after all, it's a developer-facing feature, so // should be low risk. diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index f8da5e24a0..54490ede93 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -70,12 +70,12 @@ void main() { return const SizedBox.shrink(); }))); // First, shows a loading page instead of child. - check(tester.any(find.byType(CircularProgressIndicator))).isTrue(); + check(find.byType(CircularProgressIndicator)).findsOne(); check(globalStore).isNull(); await tester.pump(); // Then after loading, mounts child instead, with provided store. - check(tester.any(find.byType(CircularProgressIndicator))).isFalse(); + check(find.byType(CircularProgressIndicator)).findsNothing(); check(globalStore).identicalTo(testBinding.globalStore); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -84,6 +84,56 @@ void main() { .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); }); + testWidgets('GlobalStoreWidget awaits blockingFuture', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes… + completer.complete(); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + + testWidgets('GlobalStoreWidget handles failed blockingFuture like success', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes, even with an error… + completer.completeError(Exception('oops')); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { addTearDown(testBinding.reset); diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index a3fc13dac9..57e8af8e29 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -57,11 +57,12 @@ void main() { return find.byType(SubscriptionItem).evaluate().length; } - testWidgets('smoke', (tester) async { + testWidgets('empty', (tester) async { await setupStreamListPage(tester, subscriptions: []); check(getItemCount()).equals(0); check(isPinnedHeaderInTree()).isFalse(); check(isUnpinnedHeaderInTree()).isFalse(); + check(find.text('You are not subscribed to any channels yet.')).findsOne(); }); testWidgets('basic subscriptions', (tester) async { diff --git a/test/widgets/theme_test.dart b/test/widgets/theme_test.dart index 8510d42b7c..678736767d 100644 --- a/test/widgets/theme_test.dart +++ b/test/widgets/theme_test.dart @@ -178,5 +178,13 @@ void main() { check(colorSwatchFor(element, subscription)) .isSameColorSwatchAs(ChannelColorSwatch.dark(baseColor)); }); + + testWidgets('fallback to default base color when no subscription', (tester) async { + await tester.pumpWidget(const TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + check(colorSwatchFor(element, null)).isSameColorSwatchAs( + ChannelColorSwatch.light(kDefaultChannelColorSwatchBaseColor)); + }); }); } 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(); + }); + }); +} diff --git a/tools/check b/tools/check index 7b02ff70c4..4f839e1450 100755 --- a/tools/check +++ b/tools/check @@ -426,6 +426,7 @@ run_pigeon() { local outputs=( lib/host/'*'.g.dart android/'*'.g.kt + ios/'*'.g.swift ) # Omitted from this check: diff --git a/tools/content/check-features b/tools/content/check-features index 76c00f1ce9..7b4698c099 100755 --- a/tools/content/check-features +++ b/tools/content/check-features @@ -29,6 +29,11 @@ The steps are: file. This wraps around tools/content/unimplemented_features_test.dart. + katex-check + Check for unimplemented KaTeX features. This requires the corpus + directory \`CORPUS_DIR\` to contain at least one corpus file. + This wraps around tools/content/unimplemented_katex_test.dart. + Options: --config @@ -50,7 +55,7 @@ opt_verbose= opt_steps=() while (( $# )); do case "$1" in - fetch|check) opt_steps+=("$1"); shift;; + fetch|check|katex-check) opt_steps+=("$1"); shift;; --config) shift; opt_zuliprc="$1"; shift;; --verbose) opt_verbose=1; shift;; --help) usage; exit 0;; @@ -98,11 +103,19 @@ run_check() { || return 1 } +run_katex_check() { + flutter test tools/content/unimplemented_katex_test.dart \ + --dart-define=corpusDir="$opt_corpus_dir" \ + --dart-define=verbose="$opt_verbose" \ + || return 1 +} + for step in "${opt_steps[@]}"; do echo "Running ${step}" case "${step}" in fetch) run_fetch ;; check) run_check ;; + katex-check) run_katex_check ;; *) echo >&2 "Internal error: unknown step ${step}" ;; esac done diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart new file mode 100644 index 0000000000..80b0f482a7 --- /dev/null +++ b/tools/content/unimplemented_katex_test.dart @@ -0,0 +1,166 @@ +// Override `flutter test`'s default timeout +@Timeout(Duration(minutes: 10)) +library; + +import 'dart:io'; +import 'dart:math'; + +import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/settings.dart'; + +import '../../test/model/binding.dart'; +import 'model.dart'; + +void main() async { + TestZulipBinding.ensureInitialized(); + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + + Future checkForKatexFailuresInFile(File file) async { + int totalMessageCount = 0; + final Set katexMessageIds = {}; + final Set failedKatexMessageIds = {}; + int totalMathBlockNodes = 0; + int failedMathBlockNodes = 0; + int totalMathInlineNodes = 0; + int failedMathInlineNodes = 0; + + final failedMessageIdsByReason = >{}; + final failedMathNodesByReason = >{}; + + void walk(int messageId, DiagnosticsNode node) { + final value = node.value; + if (value is UnimplementedNode) return; + + for (final child in node.getChildren()) { + walk(messageId, child); + } + + if (value is! MathNode) return; + katexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): totalMathBlockNodes++; + case MathInlineNode(): totalMathInlineNodes++; + } + + if (value.nodes != null) return; + failedKatexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): failedMathBlockNodes++; + case MathInlineNode(): failedMathInlineNodes++; + } + + final hardFailReason = value.debugHardFailReason; + final softFailReason = value.debugSoftFailReason; + int failureCount = 0; + + if (hardFailReason != null) { + final firstLine = hardFailReason.stackTrace.toString().split('\n').first; + final reason = 'hard fail: ${hardFailReason.error} "$firstLine"'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + + if (softFailReason != null) { + for (final cssClass in softFailReason.unsupportedCssClasses) { + final reason = 'unsupported css class: $cssClass'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + for (final cssProp in softFailReason.unsupportedInlineCssProperties) { + final reason = 'unsupported inline css property: $cssProp'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + } + + if (failureCount == 0) { + final reason = 'unknown'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + } + } + + await for (final message in readMessagesFromJsonl(file)) { + totalMessageCount++; + walk(message.id, parseContent(message.content).toDiagnosticsNode()); + } + + final buf = StringBuffer(); + buf.writeln(); + buf.writeln('Out of $totalMessageCount total messages,' + ' ${katexMessageIds.length} of them were KaTeX containing messages' + ' and ${failedKatexMessageIds.length} of those failed.'); + buf.writeln('There were $totalMathBlockNodes math block nodes out of which $failedMathBlockNodes failed.'); + buf.writeln('There were $totalMathInlineNodes math inline nodes out of which $failedMathInlineNodes failed.'); + buf.writeln(); + + for (final MapEntry(key: reason, value: messageIds) in failedMessageIdsByReason.entries.sorted( + (a, b) => b.value.length.compareTo(a.value.length), + )) { + final failedMathNodes = failedMathNodesByReason[reason]!.toList(); + failedMathNodes.shuffle(); + final oldestId = messageIds.reduce(min); + final newestId = messageIds.reduce(max); + + buf.writeln('Because of $reason:'); + buf.writeln(' ${messageIds.length} messages failed.'); + buf.writeln(' Oldest message: $oldestId, Newest message: $newestId'); + if (!_verbose) { + buf.writeln(); + continue; + } + + buf.writeln(' Message IDs (up to 100): ${messageIds.take(100).join(', ')}'); + buf.writeln(' TeX source (up to 30):'); + for (final node in failedMathNodes.take(30)) { + switch (node) { + case MathBlockNode(): + buf.writeln(' ```math'); + for (final line in node.texSource.split('\n')) { + buf.writeln(' $line'); + } + buf.writeln(' ```'); + case MathInlineNode(): + buf.writeln(' \$\$ ${node.texSource} \$\$'); + } + } + buf.writeln(' HTML (up to 3):'); + for (final node in failedMathNodes.take(3)) { + buf.writeln(' ${node.debugHtmlText}'); + } + buf.writeln(); + } + + check(failedKatexMessageIds.length, because: buf.toString()).equals(0); + } + + final corpusFiles = _getCorpusFiles(); + + if (corpusFiles.isEmpty) { + throw Exception('No corpus found in directory "$_corpusDirPath" to check' + ' for katex failures.'); + } + + group('Check for katex failures in', () { + for (final file in corpusFiles) { + test(file.path, () => checkForKatexFailuresInFile(file)); + } + }); +} + +const String _corpusDirPath = String.fromEnvironment('corpusDir'); + +const bool _verbose = int.fromEnvironment('verbose', defaultValue: 0) != 0; + +Iterable _getCorpusFiles() { + final corpusDir = Directory(_corpusDirPath); + return corpusDir.existsSync() ? corpusDir.listSync().whereType() : []; +} diff --git a/tools/generate-logos b/tools/generate-logos index 750cf1a091..a1c95c37d6 100755 --- a/tools/generate-logos +++ b/tools/generate-logos @@ -45,9 +45,8 @@ jq --version >/dev/null 2>&1 \ || die "Need jq -- try 'apt install jq'." -# White Z above "BETA", on transparent background. -# TODO(#715): use the non-beta version -src_icon_foreground="${root_dir}"/assets/app-icons/zulip-white-z-beta-on-transparent.svg +# White Z, on transparent background. +src_icon_foreground="${root_dir}"/assets/app-icons/zulip-white-z-on-transparent.svg # Gradient-colored square, full-bleed. src_icon_background="${root_dir}"/assets/app-icons/zulip-gradient.svg @@ -55,8 +54,7 @@ src_icon_background="${root_dir}"/assets/app-icons/zulip-gradient.svg # Combination of ${src_icon_foreground} upon ${src_icon_background}... # more or less. (The foreground layer is larger, with less padding, # and the SVG is different in random other ways.) -# TODO(#715): use the non-beta version -src_icon_combined="${root_dir}"/assets/app-icons/zulip-beta-combined.svg +src_icon_combined="${root_dir}"/assets/app-icons/zulip-combined.svg make_one_ios_app_icon() {