diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae781c17d..4ff2bfbe7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,12 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): Around 2025-05, Flutter upstream stopped making + # tags within the main/master branch. Get that fixed: + # https://github.com/zulip/zulip-flutter/issues/1710 + # Pending that, fetch more than 1000 commits. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index fb676042ff..7703fd6ec1 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -29,8 +29,9 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): See ci.yml for why we fetch more than 1000. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/README.md b/README.md index a8d734d45c..93273edc88 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][]. - -[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 +This app [was launched][] as the main Zulip mobile app +in June 2025. +It replaced the [previous Zulip mobile app][] built with React Native. +[was launched]: https://blog.zulip.com/flutter-mobile-app-launch +[previous Zulip mobile app]: https://github.com/zulip/zulip-mobile#readme -## Using Zulip -To use Zulip on iOS or Android, install the [official mobile Zulip client][]. +## Get the app -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 @@ -42,7 +42,7 @@ browsing through recent commits and the codebase, and the Zulip guide to Git. To find possible issues to work on, see our [project board][]. -Look for issues up through the "Launch" milestone, +Look for issues in the earliest milestone, and that aren't already assigned. Follow the Zulip guide to [picking an issue to work on][], @@ -55,6 +55,7 @@ and describing your progress. [your first codebase contribution]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#your-first-codebase-contribution [what makes a great Zulip contributor]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#what-makes-a-great-zulip-contributor +[project board]: https://github.com/orgs/zulip/projects/5/views/4 [picking an issue to work on]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on @@ -108,7 +109,7 @@ Two specific points to expand on: [commit-style]: https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html -## Getting started in developing this beta app +## Getting started in developing ### Setting up diff --git a/android/app/build.gradle b/android/app/build.gradle index 84ad671523..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. diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml index a3d1aeac5c..be85415f4e 100644 --- a/android/app/lint-baseline.xml +++ b/android/app/lint-baseline.xml @@ -1,12 +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 bf7487203d..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.10.0 +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 0af2956cea..b251272804 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.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-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_ar.arb b/assets/l10n/app_ar.arb index 5ca1208723..ff082c9a15 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -1,11 +1,20 @@ { "wildcardMentionAll": "الجميع", + "@wildcardMentionAll": {}, "wildcardMentionEveryone": "الكل", + "@wildcardMentionEveryone": {}, "wildcardMentionChannel": "القناة", + "@wildcardMentionChannel": {}, "wildcardMentionStream": "الدفق", + "@wildcardMentionStream": {}, "wildcardMentionTopic": "الموضوع", + "@wildcardMentionTopic": {}, "wildcardMentionChannelDescription": "إخطار القناة", + "@wildcardMentionChannelDescription": {}, "wildcardMentionStreamDescription": "إخطار الدفق", + "@wildcardMentionStreamDescription": {}, "wildcardMentionAllDmDescription": "إخطار المستلمين", - "wildcardMentionTopicDescription": "إخطار الموضوع" + "@wildcardMentionAllDmDescription": {}, + "wildcardMentionTopicDescription": "إخطار الموضوع", + "@wildcardMentionTopicDescription": {} } diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index 0967ef424b..819b687ea4 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -1 +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" + } + } + }, + "topicsButtonTooltip": "Themen", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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 d11bf43eda..c24f23dce9 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": { @@ -373,9 +397,13 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "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": { @@ -397,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.", @@ -478,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." @@ -713,6 +773,18 @@ "@yesterday": { "description": "Term to use to reference the previous day." }, + "invisibleMode": "Invisible mode", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Error turning on invisible mode. Please try again.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Error turning off invisible mode. Please try again.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, "userRoleOwner": "Owner", "@userRoleOwner": { "description": "Label for UserRole.owner" @@ -737,10 +809,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." @@ -749,6 +837,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." @@ -765,10 +857,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." }, + "topicsButtonTooltip": "Topics", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, "channelFeedButtonTooltip": "Channel feed", "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" @@ -789,10 +889,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" @@ -864,6 +960,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.", @@ -899,6 +999,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" @@ -911,9 +1055,9 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountNotFound": "The account associated with this notification could not be found.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { @@ -935,6 +1079,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_fr.arb b/assets/l10n/app_fr.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_fr.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb new file mode 100644 index 0000000000..ad9ece2ce2 --- /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." + }, + "topicsButtonTooltip": "Argomenti", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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_ja.arb b/assets/l10n/app_ja.arb index a66aede69e..dcbe99f1b7 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -16,5 +16,177 @@ "userRoleGuest": "ゲスト", "@userRoleGuest": {}, "userRoleUnknown": "不明", - "@userRoleUnknown": {} + "@userRoleUnknown": {}, + "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" + }, + "upgradeWelcomeDialogTitle": "新しいZulipアプリへようこそ!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "aboutPageTapToView": "タップして表示", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "upgradeWelcomeDialogMessage": "より速く、洗練されたデザインで、これまでと同じ使い心地をお楽しみいただけます。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text 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." + }, + "settingsPageTitle": "設定", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "アカウントを切り替える", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "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." + }, + "actionSheetOptionListOfTopics": "トピック一覧", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMarkChannelAsRead": "チャンネルを既読にする", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionMuteTopic": "トピックをミュート", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "トピックのミュートを解除", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionFollowTopic": "トピックをフォロー", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionUnfollowTopic": "トピックのフォローを解除", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "解決済みにする", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic 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." + }, + "actionSheetOptionHideMutedMessage": "ミュートしたメッセージを再び非表示にする", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionShare": "共有", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionQuoteMessage": "メッセージを引用", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message 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)." + }, + "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." + }, + "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" + } + } + } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 8de9527def..1350cf82a4 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." }, @@ -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." @@ -1049,9 +1037,9 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, "discardDraftConfirmationDialogConfirmButton": "Odrzuć", "@discardDraftConfirmationDialogConfirmButton": { @@ -1072,5 +1060,169 @@ "composeBoxBannerButtonSave": "Zapisz", "@composeBoxBannerButtonSave": { "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "topicsButtonTooltip": "Wątki", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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." + }, + "invisibleMode": "Tryb ukrycia", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Problem z włączeniem trybu ukrycia. Spróbuj ponownie.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 57cf48e3e0..d1cd596e2b 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,235 @@ "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." + }, + "topicsButtonTooltip": "Темы", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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." + }, + "invisibleMode": "Режим невидимости", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Не удалось включить режим невидимости. Повторите попытку позже.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Не удалось отключить режим невидимости. Повторите попытку позже.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." } } 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..8b0dfad5db --- /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." + }, + "topicsButtonTooltip": "Teme", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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 686b1345a6..4dc1c5421c 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -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.", @@ -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." }, @@ -959,10 +955,6 @@ "@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,235 @@ "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." + }, + "topicsButtonTooltip": "Теми", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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." + }, + "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." + }, + "invisibleMode": "Невидимий режим", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Помилка ввімкнення режиму невидимості. Спробуйте ще раз.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Помилка вимкнення режиму невидимості. Спробуйте ще раз.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "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." } } 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..e47d033f4b --- /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." + }, + "topicsButtonTooltip": "话题", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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..d784114d3a --- /dev/null +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -0,0 +1,1226 @@ +{ + "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" + } + } + }, + "topicsButtonTooltip": "話題", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "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." + }, + "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." + }, + "upgradeWelcomeDialogMessage": "您將在更快、更流暢的版本中享受熟悉的體驗。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text 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." + }, + "logOutConfirmationDialogMessage": "要在未來使用此帳號,您將需要重新輸入您組織的網址和您的帳號資訊。", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "errorCouldNotShowUserProfile": "無法顯示使用者設定檔。", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "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." + }, + "errorCouldNotFetchMessageSource": "無法取得訊息來源。", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "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" + } + } + }, + "errorFilesTooLarge": "{num, plural, =1{檔案} 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" + } + } + }, + "errorFilesTooLargeTitle": "{num, plural, =1{檔案} other{檔案}}太大", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "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." + }, + "errorMessageEditNotSaved": "訊息沒有儲存", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorServerMessage": "伺服器回應:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "連接 Zulip 時發生錯誤。重試中…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorConnectingToServerDetails": "連接 Zulip {serverUrl} 時發生錯誤。將重試:\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" + } + } + }, + "errorHandlingEventTitle": "處理 Zulip 事件時發生錯誤。重新連線中…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "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')" + } + } + }, + "errorBannerDeactivatedDmLabel": "您無法向已停用的使用者發送訊息。", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "errorBannerCannotPostInChannelLabel": "您沒有權限在此頻道發佈訊息。", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "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.)" + }, + "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." + }, + "newDmSheetComposeButtonLabel": "編寫", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetNoUsersFound": "找不到使用者", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxSelfDmContentHint": "記下些什麼", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "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." + }, + "composeBoxEnterTopicOrSkipHintText": "輸入議題(留空則使用「{defaultTopicName}」)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "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." + }, + "dmsWithYourselfPageTitle": "私訊給自己", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "messageListGroupYouAndOthers": "您與 {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithOthersPageTitle": "與 {others} 的私訊", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "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." + }, + "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." + }, + "contentValidationErrorEmpty": "您沒有要發送的內容!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "discardDraftConfirmationDialogTitle": "要捨棄您正在編寫的訊息嗎?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForEditConfirmationDialogMessage": "當您編輯訊息時,編輯框中原有的內容將被捨棄。", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "discardDraftForOutboxConfirmationDialogMessage": "當您還原未發送的訊息時,編輯框中原有的內容將被捨棄。", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, 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." + }, + "errorDialogContinue": "OK", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "snackBarDetails": "詳細資訊", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "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." + }, + "loginMethodDivider": "或", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "topicValidationErrorTooLong": "議題長度不得超過 60 個字元。", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "topicValidationErrorMandatoryButEmpty": "此組織要求必須填寫議題。", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorServerVersionUnsupportedMessage": "{url} 執行的 Zulip Server 為 {zulipVersion},此版本已不受支援。最低支援版本為 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" + } + } + }, + "errorInvalidApiKeyMessage": "您在 {url} 的帳號無法通過驗證。請重新登入或使用其他帳號。", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "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" + } + } + }, + "errorRequestFailed": "網路請求失敗:HTTP 狀態碼為 {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorUnsupportedScheme": "伺服器 URL 必須以 http:// 或 https:// 開頭。", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAsReadComplete": "已標為已讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsReadInProgress": "正在標記訊息為已讀…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "標記為已讀失敗", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadComplete": "已標為未讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorMarkAsUnreadFailedTitle": "標記為未讀失敗", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "invisibleMode": "隱身模式", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "啟用隱身模式時發生錯誤。請再試一次。", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "關閉隱身模式時發生錯誤。請再試一次。", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "userRoleUnknown": "未知", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "inboxEmptyPlaceholder": "您的收件匣中沒有未讀訊息。請使用下方按鈕檢視整合訊息流或頻道清單。", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' 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." + }, + "starredMessagesPageTitle": "已加星號的訊息", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsEmptyPlaceholder": "您尚未訂閱任何頻道。", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "mainMenuMyProfile": "我的設定檔", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "notifGroupDmConversationLabel": "{senderFullName} 傳送給您和 {numOthers, plural, =1{1 位其他對象、} other{{numOthers} 位其他對象}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "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." + }, + "onePersonTyping": "{typist} 正在輸入…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "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." + }, + "wildcardMentionAll": "全部", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM 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." + }, + "wildcardMentionStreamDescription": "通知串流", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "通知收件人", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "messageIsEditedLabel": "已編輯", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsMovedLabel": "已移動", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "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.)" + }, + "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" + } + } + }, + "openLinksWithInAppBrowser": "使用應用程式內建瀏覽器開啟連結", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetQuestionMissing": "沒有問題。", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "pollWidgetOptionsMissing": "此投票尚未有任何選項。", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "initialAnchorSettingTitle": "開啟訊息串於", + "@initialAnchorSettingTitle": { + "description": "Title 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." + }, + "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." + }, + "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." + }, + "markReadOnScrollSettingConversationsDescription": "只有在檢視單一議題或私人訊息對話時,訊息才會自動標記為已讀。", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "experimentalFeatureSettingsWarning": "這些選項啟用的功能仍在開發中,尚未完善。它們可能無法正常運作,且可能導致應用程式其他部分出現問題。\n\n這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "errorNotificationOpenAccountNotFound": "找不到與此通知相關聯的帳號。", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "errorReactionAddingFailedTitle": "新增表情反應失敗", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "移除表情反應失敗", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "noEarlierMessages": "沒有更早的訊息", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "revealButtonLabel": "顯示訊息", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "scrollToBottomTooltip": "捲動至底部", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + } +} diff --git a/docs/changelog.md b/docs/changelog.md index d2d50c022b..d35fee742c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,424 @@ ## Unreleased +## 30.0.262 (2025-07-24) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* Fix "general chat" to show new messages as normal + after opening via a notification. (#1717) +* Set your status emoji and status message. (#198) +* Fix deactivated users appearing in "New DM" screen. (#1743) +* Follow your personal setting for 24-hour or 12-hour time + format. (#1015) +* Translation updates. (PR #1726, PR #1750) + + +### Highlights for developers + +* User-visible changes not described above: + * Avoid showing potentially wrong result if encountering + a KaTeX vlist with unexpected inline style properties. + (c4503b492; revision to PR #1698, for #46) + * Fix double-application of negative margin on KaTeX vlist items. + (64956b8f0; revision to PR #1559, for #46) + * Better semantics on settings radio buttons, for a11y. (#1545) + +* Store and substore refactors: RealmStore; proxy mixins; + move more methods to individual substores. (PR #1736) + +* Resolved in main: #1710, #1712, PR #1698, #1717, PR #1559, #46, + PR #1719, PR #1726, #197, #1545, PR #1736, #1743, #1015, PR #1750 + +* Resolved in the experimental branch: + * #740 via PR #1700 + * #198 via PR #1701 + + +## 30.0.261 (2025-07-09) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* See who reacted to a message. (#740) +* Turn invisible mode on and off. (#1578) +* Less empty space at end of message feed. (PR #1628) +* After you return to the app, it resumes its connection + more quickly. (#979) +* The message long-press menu shows the message and + when it was sent. (#217) +* (iOS) Fixed white flash on opening app in dark mode. (#1149) + + +### Highlights for developers + +* User-visible changes not described above: + * Upgraded Flutter and other dependencies. (#1684) + * Case-insensitive topics in unreads and other data + structures. (#980) + * Icon for topic-list button, rather than "TOPICS". (#1532) + * Status emoji properly follow system text-scale setting. + (revision to PR #1629, for #197) + * Status text's font size increased. + (revision to PR #1629, for #197) + * Fixed scroll behavior of math blocks in RTL locales. + (revision to PR #1452, at 5677317bc, for #46) + * Fixed vertical alignment within TeX math expressions. + (e8e8f4105; revision to PR #1452, for #46) + * Adjusted color of icons in action sheets. + (included in PR #1631, for #1578) + * Removed blank space for absent status emoji. + (revision to PR #1629, for #197) + * Adjusted choice of "Close" vs "Cancel" in action sheets. + (included in PR #1700, for #740) + * Translation updates. (PR #1682) + +* Workarounds in our CI for a Flutter infra issue with the + "main" branch. (PR #1690, PR #1691; flutter/flutter#171833) + +* Resolved in main: #296, PR #1684, PR #1628, #980, #1532, #662, + #217, #1578, #1149, PR #1629, #979, PR #1682, PR #1452 + +* Resolved in the experimental branch: + * more toward #46 via PR #1698 + * further toward #46 via PR #1559 + * #197 via PR #1702 + * #740 via PR #1700 + + +## 30.0.260 (2025-07-03) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* (iOS) Fixed a bug causing duplicate notifications. (#1617) +* The app offers a search view. (#252) +* See the status emoji and status messages of other users. (#197) +* Initial support for showing audio files in messages, + an upcoming Zulip feature. (#1665) +* Translation updates. (PR #1642) + + +### Highlights for developers + +* User-visible changes not described above: + * More recipient headers in mentions/starred. (#1637) + * Tap message in starred/mentions to open conversation. (#1621) + * Clearer placeholder text when no messages. (#1555) + * Correctly apply font-size to "em" on the same KaTeX span + (if that situation is possible). (f003f58ed, in PR #1609) + +* Resolved by server-side changes: #1617 + +* Resolved in main: #1637, #1621, PR #1560 (toward #296), #1555, + PR #1609 (toward #46), PR #1601 (toward #46), + PR #1600 (toward #46), PR #1658, #1665, #252, PR #1642 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #197 via PR #1629 + + +## 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 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 1bd78a4b7f..12e39fa71e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,37 +37,37 @@ 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.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (= 11.10.0) + - FirebaseMessaging (~> 11.15.0) + - firebase_core (3.15.1): + - Firebase/CoreOnly (= 11.15.0) - Flutter - - firebase_messaging (15.2.5): - - Firebase/Messaging (= 11.10.0) + - firebase_messaging (15.2.9): + - Firebase/Messaging (= 11.15.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.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.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): @@ -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,13 +220,13 @@ 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 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5 + firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 @@ -236,10 +236,10 @@ SPEC CHECKSUMS: 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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..d1c3c5953d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 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 */ @@ -46,8 +46,8 @@ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 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 */ @@ -110,11 +110,11 @@ 3752899A2AF472D400475D9C /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -192,7 +192,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, @@ -297,6 +296,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -312,14 +312,6 @@ name = Main.storyboard; sourceTree = ""; }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -388,7 +380,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 +510,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 +534,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..068ca5c1df 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,69 @@ 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) + + // Use `DesignVariables.mainBackground` color as the background color + // of the default UIView. + window?.backgroundColor = UIColor(named: "LaunchBackground"); + + 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/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000000..43a02dcfa1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xF0", + "red" : "0xF0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1D", + "green" : "0x1D", + "red" : "0x1D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2fd4..0000000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b70..0000000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7c9..0000000000 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5f5d9c5ea2..489fb6d350 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 @@ -52,8 +52,11 @@ fetch remote-notification - UILaunchStoryboardName - LaunchScreen + UILaunchScreen + + UIColorName + LaunchBackground + UIMainStoryboardFile Main UISupportedInterfaceOrientations diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..b9f252796a --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,335 @@ +// Autogenerated from Pigeon (v25.5.0), 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/model/events.dart b/lib/api/model/events.dart index 62789333e1..6070616387 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -30,6 +30,14 @@ sealed class Event { default: return UnexpectedEvent.fromJson(json); } case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json); + case 'user_group': + switch (json['op'] as String) { + case 'add': return UserGroupAddEvent.fromJson(json); + case 'update': return UserGroupUpdateEvent.fromJson(json); + // TODO(#1687): add_members, remove_members, add_subgroups, remove_subgroups + case 'remove': return UserGroupRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'realm_user': switch (json['op'] as String) { case 'add': return RealmUserAddEvent.fromJson(json); @@ -61,7 +69,9 @@ sealed class Event { default: return UnexpectedEvent.fromJson(json); } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers + case 'user_status': return UserStatusEvent.fromJson(json); 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); @@ -73,6 +83,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 @@ -164,10 +175,13 @@ class UserSettingsUpdateEvent extends Event { final value = json['value']; switch (UserSettingName.fromRawString(json['property'] as String)) { case UserSettingName.twentyFourHourTime: + return TwentyFourHourTimeMode.fromApiValue(value as bool?); case UserSettingName.displayEmojiReactionUsers: return value as bool; case UserSettingName.emojiset: return Emojiset.fromRawString(value as String); + case UserSettingName.presenceEnabled: + return value as bool; case null: return null; } @@ -204,6 +218,85 @@ class CustomProfileFieldsEvent extends Event { Map toJson() => _$CustomProfileFieldsEventToJson(this); } +/// A Zulip event of type `user_group`. +/// +/// See API docs starting at: +/// https://zulip.com/api/get-events#user_group-add +sealed class UserGroupEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'user_group'; + + String get op; + + UserGroupEvent({required super.id}); +} + +/// A [UserGroupEvent] with op `add`: https://zulip.com/api/get-events#user_group-add +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupAddEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add'; + + final UserGroup group; + + UserGroupAddEvent({required super.id, required this.group}); + + factory UserGroupAddEvent.fromJson(Map json) => _$UserGroupAddEventFromJson(json); + + @override + Map toJson() => _$UserGroupAddEventToJson(this); +} + +/// A [UserGroupEvent] with op `update`: https://zulip.com/api/get-events#user_group-update +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupUpdateEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'update'; + + final int groupId; + final UserGroupUpdateData data; + + UserGroupUpdateEvent({required super.id, required this.groupId, required this.data}); + + factory UserGroupUpdateEvent.fromJson(Map json) => _$UserGroupUpdateEventFromJson(json); + + @override + Map toJson() => _$UserGroupUpdateEventToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupUpdateData { + final String? name; + final String? description; + final bool? deactivated; + + UserGroupUpdateData({required this.name, required this.description, required this.deactivated}); + + factory UserGroupUpdateData.fromJson(Map json) => _$UserGroupUpdateDataFromJson(json); + + Map toJson() => _$UserGroupUpdateDataToJson(this); +} + +/// A [UserGroupEvent] with op `remove`: https://zulip.com/api/get-events#user_group-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupRemoveEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove'; + + final int groupId; + + UserGroupRemoveEvent({required super.id, required this.groupId}); + + factory UserGroupRemoveEvent.fromJson(Map json) => _$UserGroupRemoveEventFromJson(json); + + @override + Map toJson() => _$UserGroupRemoveEventToJson(this); +} + /// A Zulip event of type `realm_user`. /// /// The corresponding API docs are in several places for @@ -706,6 +799,41 @@ class SubscriptionPeerRemoveEvent extends SubscriptionEvent { Map toJson() => _$SubscriptionPeerRemoveEventToJson(this); } +/// A Zulip event of type `user_status`: https://zulip.com/api/get-events#user_status +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class UserStatusEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'user_status'; + + final int userId; + + @JsonKey(readValue: _readChange) + final UserStatusChange change; + + static Object? _readChange(Map json, String key) { + assert(json is Map); // value came through `fromJson` with this type + return json; + } + + UserStatusEvent({ + required super.id, + required this.userId, + required this.change, + }); + + factory UserStatusEvent.fromJson(Map json) => + _$UserStatusEventFromJson(json); + + @override + Map toJson() => { + 'id': id, + 'type': type, + 'user_id': userId, + ...change.toJson(), + }; +} + /// A Zulip event of type `user_topic`: https://zulip.com/api/get-events#user_topic @JsonSerializable(fieldRename: FieldRename.snake) class UserTopicEvent extends Event { @@ -733,6 +861,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 { @@ -1027,10 +1173,7 @@ class UpdateMessageFlagsRemoveEvent extends UpdateMessageFlagsEvent { factory UpdateMessageFlagsRemoveEvent.fromJson(Map json) { final result = _$UpdateMessageFlagsRemoveEventFromJson(json); // Crunchy-shell validation - if ( - result.flag == MessageFlag.read - && true // (we assume `event_types` has `message` and `update_message_flags`) - ) { + if (result.flag == MessageFlag.read) { result.messageDetails as Map; } return result; @@ -1176,6 +1319,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 94fe288150..1aa93ef47b 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) => @@ -60,7 +59,7 @@ Map _$UserSettingsUpdateEventToJson( 'id': instance.id, 'type': instance.type, 'op': instance.op, - 'property': _$UserSettingNameEnumMap[instance.property], + 'property': instance.property, 'value': instance.value, }; @@ -68,16 +67,16 @@ const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', + UserSettingName.presenceEnabled: 'presence_enabled', }; 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( @@ -88,6 +87,69 @@ Map _$CustomProfileFieldsEventToJson( 'fields': instance.fields, }; +UserGroupAddEvent _$UserGroupAddEventFromJson(Map json) => + UserGroupAddEvent( + id: (json['id'] as num).toInt(), + group: UserGroup.fromJson(json['group'] as Map), + ); + +Map _$UserGroupAddEventToJson(UserGroupAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group': instance.group, + }; + +UserGroupUpdateEvent _$UserGroupUpdateEventFromJson( + Map json, +) => UserGroupUpdateEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + data: UserGroupUpdateData.fromJson(json['data'] as Map), +); + +Map _$UserGroupUpdateEventToJson( + UserGroupUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'data': instance.data, +}; + +UserGroupUpdateData _$UserGroupUpdateDataFromJson(Map json) => + UserGroupUpdateData( + name: json['name'] as String?, + description: json['description'] as String?, + deactivated: json['deactivated'] as bool?, + ); + +Map _$UserGroupUpdateDataToJson( + UserGroupUpdateData instance, +) => { + 'name': instance.name, + 'description': instance.description, + 'deactivated': instance.deactivated, +}; + +UserGroupRemoveEvent _$UserGroupRemoveEventFromJson( + Map json, +) => UserGroupRemoveEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), +); + +Map _$UserGroupRemoveEventToJson( + UserGroupRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, +}; + RealmUserAddEvent _$RealmUserAddEventFromJson(Map json) => RealmUserAddEvent( id: (json['id'] as num).toInt(), @@ -122,8 +184,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 +213,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?, ); @@ -255,10 +317,9 @@ Map _$SavedSnippetsRemoveEventToJson( 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) => @@ -272,10 +333,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) => @@ -331,10 +391,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( @@ -403,14 +462,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( @@ -427,14 +484,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( @@ -447,6 +502,15 @@ Map _$SubscriptionPeerRemoveEventToJson( 'user_ids': instance.userIds, }; +UserStatusEvent _$UserStatusEventFromJson(Map json) => + UserStatusEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + change: UserStatusChange.fromJson( + UserStatusEvent._readChange(json, 'change') as Map, + ), + ); + UserTopicEvent _$UserTopicEventFromJson(Map json) => UserTopicEvent( id: (json['id'] as num).toInt(), @@ -477,6 +541,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( @@ -498,14 +577,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') @@ -549,18 +626,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) => @@ -582,10 +657,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, ); @@ -609,10 +683,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), @@ -639,15 +712,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( @@ -699,10 +770,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) => @@ -719,6 +789,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 f4cc2fe5fc..d9734582c6 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,8 +47,17 @@ 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 realmUserGroups; + final List recentPrivateConversations; final List? savedSnippets; // TODO(server-10) @@ -56,13 +68,17 @@ class InitialSnapshot { final List streams; - // Servers pre-5.0 don't have `user_settings`, and instead provide whatever - // user settings they support at toplevel in the initial snapshot. Since we're - // likely to desupport pre-5.0 servers before wide release, we prefer to - // ignore the toplevel fields and use `user_settings` where present instead, - // even at the expense of functionality with pre-5.0 servers. - // TODO(server-5) remove pre-5.0 comment - final UserSettings? userSettings; // TODO(server-5) + // In register-queue, the name of this field is the singular "user_status", + // even though it actually contains user status information for all the users + // that the self-user has access to. Therefore, we prefer to use the plural form. + // + // The API expresses each status as a change from the "zero status" (see + // [UserStatus.zero]), with entries omitted for users whose status is the + // zero status. + @JsonKey(name: 'user_status') + final Map userStatuses; + + final UserSettings userSettings; final List? userTopics; // TODO(server-6) @@ -84,6 +100,8 @@ class InitialSnapshot { final bool realmAllowMessageEditing; final int? realmMessageContentEditLimitSeconds; + final bool realmPresenceDisabled; + final Map realmDefaultExternalAccounts; final int maxFileUploadSizeMib; @@ -129,15 +147,21 @@ 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.realmUserGroups, required this.recentPrivateConversations, required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, + required this.userStatuses, required this.userSettings, required this.userTopics, required this.realmWildcardMentionPolicy, @@ -145,6 +169,7 @@ class InitialSnapshot { required this.realmWaitingPeriodThreshold, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, + required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, @@ -234,19 +259,27 @@ class RecentDmConversation { /// in . @JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true) class UserSettings { - bool twentyFourHourTime; + @JsonKey( + fromJson: TwentyFourHourTimeMode.fromApiValue, + toJson: TwentyFourHourTimeMode.staticToJson, + ) + TwentyFourHourTimeMode twentyFourHourTime; + bool? displayEmojiReactionUsers; // TODO(server-6) Emojiset emojiset; + bool presenceEnabled; // TODO more, as needed. When adding a setting here, please also: // (1) add it to the [UserSettingName] enum // (2) then re-run the command to refresh the .g.dart files // (3) handle the event that signals an update to the setting + // (4) add the setting to the [updateSettings] route binding UserSettings({ required this.twentyFourHourTime, required this.displayEmojiReactionUsers, required this.emojiset, + required this.presenceEnabled, }); factory UserSettings.fromJson(Map json) => diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 36afb0a39f..32af6a1867 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,48 +42,60 @@ 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)), ), + realmUserGroups: (json['realm_user_groups'] as List) + .map((e) => UserGroup.fromJson(e as Map)) + .toList(), recentPrivateConversations: (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.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(), + 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(), + userStatuses: (json['user_status'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + UserStatusChange.fromJson(e as Map), + ), + ), + userSettings: 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( @@ -88,10 +104,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') @@ -113,45 +128,55 @@ InitialSnapshot _$InitialSnapshotFromJson( .toList(), ); -Map _$InitialSnapshotToJson(InitialSnapshot instance) => - { - 'queue_id': instance.queueId, - 'last_event_id': instance.lastEventId, - 'zulip_feature_level': instance.zulipFeatureLevel, - 'zulip_version': instance.zulipVersion, - 'zulip_merge_base': instance.zulipMergeBase, - 'alert_words': instance.alertWords, - 'custom_profile_fields': instance.customProfileFields, - 'email_address_visibility': - _$EmailAddressVisibilityEnumMap[instance.emailAddressVisibility], - 'server_typing_started_expiry_period_milliseconds': - instance.serverTypingStartedExpiryPeriodMilliseconds, - 'server_typing_stopped_wait_period_milliseconds': - instance.serverTypingStoppedWaitPeriodMilliseconds, - 'server_typing_started_wait_period_milliseconds': - instance.serverTypingStartedWaitPeriodMilliseconds, - 'realm_emoji': instance.realmEmoji, - 'recent_private_conversations': instance.recentPrivateConversations, - 'saved_snippets': instance.savedSnippets, - 'subscriptions': instance.subscriptions, - 'unread_msgs': instance.unreadMsgs, - 'streams': instance.streams, - 'user_settings': instance.userSettings, - 'user_topics': instance.userTopics, - 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, - 'realm_mandatory_topics': instance.realmMandatoryTopics, - 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, - 'realm_allow_message_editing': instance.realmAllowMessageEditing, - 'realm_message_content_edit_limit_seconds': - instance.realmMessageContentEditLimitSeconds, - 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, - 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, - 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), - 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, - 'realm_users': instance.realmUsers, - 'realm_non_active_users': instance.realmNonActiveUsers, - 'cross_realm_bots': instance.crossRealmBots, - }; +Map _$InitialSnapshotToJson( + InitialSnapshot instance, +) => { + 'queue_id': instance.queueId, + 'last_event_id': instance.lastEventId, + 'zulip_feature_level': instance.zulipFeatureLevel, + 'zulip_version': instance.zulipVersion, + 'zulip_merge_base': instance.zulipMergeBase, + 'alert_words': instance.alertWords, + '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, + 'realm_user_groups': instance.realmUserGroups, + 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, + 'subscriptions': instance.subscriptions, + 'unread_msgs': instance.unreadMsgs, + 'streams': instance.streams, + 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), + 'user_settings': instance.userSettings, + 'user_topics': instance.userTopics, + 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, + 'realm_mandatory_topics': instance.realmMandatoryTopics, + 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, + '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(), + 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, + 'realm_users': instance.realmUsers, + 'realm_non_active_users': instance.realmNonActiveUsers, + 'cross_realm_bots': instance.crossRealmBots, +}; const _$EmailAddressVisibilityEnumMap = { EmailAddressVisibility.everyone: 1, @@ -192,10 +217,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( @@ -206,22 +230,29 @@ Map _$RecentDmConversationToJson( }; UserSettings _$UserSettingsFromJson(Map json) => UserSettings( - twentyFourHourTime: json['twenty_four_hour_time'] as bool, + twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue( + json['twenty_four_hour_time'] as bool?, + ), displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?, emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']), + presenceEnabled: json['presence_enabled'] as bool, ); const _$UserSettingsFieldMap = { 'twentyFourHourTime': 'twenty_four_hour_time', 'displayEmojiReactionUsers': 'display_emoji_reaction_users', 'emojiset': 'emojiset', + 'presenceEnabled': 'presence_enabled', }; Map _$UserSettingsToJson(UserSettings instance) => { - 'twenty_four_hour_time': instance.twentyFourHourTime, + 'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson( + instance.twentyFourHourTime, + ), 'display_emoji_reaction_users': instance.displayEmojiReactionUsers, - 'emojiset': _$EmojisetEnumMap[instance.emojiset]!, + 'emojiset': instance.emojiset, + 'presence_enabled': instance.presenceEnabled, }; const _$EmojisetEnumMap = { @@ -263,22 +294,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, ); @@ -298,10 +325,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) => @@ -315,10 +341,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( @@ -333,10 +358,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 131a51991b..1009418d08 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; import '../../model/algorithms.dart'; import 'events.dart'; import 'initial_snapshot.dart'; @@ -110,6 +111,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:" @@ -139,6 +159,119 @@ class RealmEmojiItem { Map toJson() => _$RealmEmojiItemToJson(this); } +/// A user's status, with [text] and [emoji] parts. +/// +/// If a part is null, that part is empty/unset. +/// For a [UserStatus] with all parts empty, see [zero]. +class UserStatus { + /// The text part (e.g. 'Working remotely'), or null if unset. + /// + /// This won't be the empty string. + final String? text; + + /// The emoji part, or null if unset. + final StatusEmoji? emoji; + + const UserStatus({required this.text, required this.emoji}) : assert(text != ''); + + static const UserStatus zero = UserStatus(text: null, emoji: null); + + @override + bool operator ==(Object other) { + if (other is! UserStatus) return false; + return (text, emoji) == (other.text, other.emoji); + } + + @override + int get hashCode => Object.hash(text, emoji); +} + +/// A user's status emoji, as in [UserStatus.emoji]. +class StatusEmoji { + final String emojiName; + final String emojiCode; + final ReactionType reactionType; + + const StatusEmoji({ + required this.emojiName, + required this.emojiCode, + required this.reactionType, + }) : assert(emojiName != ''), assert(emojiCode != ''); + + @override + bool operator ==(Object other) { + if (other is! StatusEmoji) return false; + return (emojiName, emojiCode, reactionType) == + (other.emojiName, other.emojiCode, other.reactionType); + } + + @override + int get hashCode => Object.hash(emojiName, emojiCode, reactionType); +} + +/// A change to part or all of a user's status. +/// +/// The absence of one of these means there is no change. +class UserStatusChange { + // final Option away; // deprecated in server-6 (FL-148); ignore + final Option text; + final Option emoji; + + const UserStatusChange({required this.text, required this.emoji}); + + UserStatus apply(UserStatus old) { + return UserStatus(text: text.or(old.text), emoji: emoji.or(old.emoji)); + } + + factory UserStatusChange.fromJson(Map json) { + return UserStatusChange( + text: _textFromJson(json), emoji: _emojiFromJson(json)); + } + + static Option _textFromJson(Map json) { + return switch (json['status_text'] as String?) { + null => OptionNone(), + '' => OptionSome(null), + final apiValue => OptionSome(apiValue), + }; + } + + static Option _emojiFromJson(Map json) { + final emojiName = json['emoji_name'] as String?; + final emojiCode = json['emoji_code'] as String?; + final reactionType = json['reaction_type'] as String?; + + if (emojiName == null || emojiCode == null || reactionType == null) { + return OptionNone(); + } else if (emojiName == '' || emojiCode == '' || reactionType == '') { + // Sometimes `reaction_type` is 'unicode_emoji' when the emoji is cleared. + // This is an accident, to be handled by looking at `emoji_code` instead: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/user.20status/near/2203132 + return OptionSome(null); + } else { + return OptionSome(StatusEmoji( + emojiName: emojiName, + emojiCode: emojiCode, + reactionType: ReactionType.fromApiValue(reactionType))); + } + } + + Map toJson() { + return { + if (text case OptionSome(:var value)) + 'status_text': value ?? '', + if (emoji case OptionSome(:var value)) + ...value == null + ? {'emoji_name': '', 'emoji_code': '', 'reaction_type': ''} + : { + 'emoji_name': value.emojiName, + 'emoji_code': value.emojiCode, + 'reaction_type': value.reactionType, + }, + }; + } +} + /// The name of a user setting that has a property in [UserSettings]. /// /// In Zulip event-handling code (for [UserSettingsUpdateEvent]), @@ -148,7 +281,9 @@ class RealmEmojiItem { enum UserSettingName { twentyFourHourTime, displayEmojiReactionUsers, - emojiset; + emojiset, + presenceEnabled, + ; /// Get a [UserSettingName] from a raw, snake-case string we recognize, else null. /// @@ -159,6 +294,37 @@ enum UserSettingName { // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` static final _byRawString = _$UserSettingNameEnumMap .map((key, value) => MapEntry(value, key)); + + String toJson() => _$UserSettingNameEnumMap[this]!; +} + +/// A value from [UserSettings.twentyFourHourTime]. +enum TwentyFourHourTimeMode { + twelveHour(apiValue: false), + twentyFourHour(apiValue: true), + + /// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.). + // TODO(#1727) actually follow this + // Not sent by current servers, but planned when most client installs accept it: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696 + // TODO(server-future) Write down what server N starts sending null; + // adjust the comment; leave a TODO(server-N) to delete the comment + localeDefault(apiValue: null), + ; + + const TwentyFourHourTimeMode({required this.apiValue}); + + final bool? apiValue; + + static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue; + + bool? toJson() => TwentyFourHourTimeMode.staticToJson(this); + + static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) { + false => twelveHour, + true => twentyFourHour, + null => localeDefault, + }; } /// As in [UserSettings.emojiset]. @@ -178,6 +344,44 @@ enum Emojiset { // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.kebab` static final _byRawString = _$EmojisetEnumMap .map((key, value) => MapEntry(value, key)); + + String toJson() => _$EmojisetEnumMap[this]!; +} + +/// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent]. +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroup { + final int id; + + // TODO(#1687) to maintain members, also act on user deactivation: https://github.com/zulip/zulip-flutter/issues/662#issuecomment-2405845356 + // List members; // TODO(#1687) track group members + // List directSubgroupIds; // TODO(#1687) track group members + + String name; + String description; + + // final int? dateCreated; // not using; ignore + // final int? creatorId; // not using; ignore + + final bool isSystemGroup; + + // TODO(server-10): [deactivated] new in FL 290; previously no groups were deactivated + @JsonKey(defaultValue: false) + bool deactivated; + + // TODO(#814): GroupSettingValue canAddMembersGroup, etc.; add to update event too + + UserGroup({ + required this.id, + required this.name, + required this.description, + required this.isSystemGroup, + required this.deactivated, + }); + + factory UserGroup.fromJson(Map json) => _$UserGroupFromJson(json); + + Map toJson() => _$UserGroupToJson(this); } /// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots]. @@ -311,6 +515,35 @@ 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:" @@ -633,53 +866,6 @@ 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; @@ -817,9 +1003,9 @@ 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; + String? matchContent; @JsonKey(name: 'match_subject') - final String? matchTopic; + String? matchTopic; static MessageEditState _messageEditStateFromJson(Object? json) { // This is a no-op so that [MessageEditState._readFromMessage] diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 67fc606031..a4608a08c8 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, @@ -88,6 +94,22 @@ Map _$RealmEmojiItemToJson(RealmEmojiItem instance) => 'author_id': instance.authorId, }; +UserGroup _$UserGroupFromJson(Map json) => UserGroup( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + description: json['description'] as String, + isSystemGroup: json['is_system_group'] as bool, + deactivated: json['deactivated'] as bool? ?? false, +); + +Map _$UserGroupToJson(UserGroup instance) => { + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + 'is_system_group': instance.isSystemGroup, + 'deactivated': instance.deactivated, +}; + User _$UserFromJson(Map json) => User( userId: (json['user_id'] as num).toInt(), deliveryEmail: json['delivery_email'] as String?, @@ -107,14 +129,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 +184,18 @@ 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, @@ -395,6 +429,7 @@ const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', + UserSettingName.presenceEnabled: 'presence_enabled', }; const _$EmojisetEnumMap = { @@ -404,6 +439,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/reaction.dart b/lib/api/model/reaction.dart index 54804d0d05..50d93924d8 100644 --- a/lib/api/model/reaction.dart +++ b/lib/api/model/reaction.dart @@ -175,4 +175,9 @@ enum ReactionType { zulipExtraEmoji; String toJson() => _$ReactionTypeEnumMap[this]!; + + static ReactionType fromApiValue(String value) => _byApiValue[value]!; + + static final _byApiValue = _$ReactionTypeEnumMap + .map((key, value) => MapEntry(value, key)); } 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 bd14521c74..589a9b8cbf 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, + 'include_deactivated_groups': 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 f55e630585..05364951cd 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -392,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, @@ -404,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, @@ -439,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/settings.dart b/lib/api/route/settings.dart new file mode 100644 index 0000000000..4e98140d76 --- /dev/null +++ b/lib/api/route/settings.dart @@ -0,0 +1,30 @@ +import '../core.dart'; +import '../model/model.dart'; + +/// https://zulip.com/api/update-settings +Future updateSettings(ApiConnection connection, { + required Map newSettings, +}) { + final params = {}; + for (final entry in newSettings.entries) { + final name = entry.key; + final valueRaw = entry.value; + final Object? value; + switch (name) { + case UserSettingName.twentyFourHourTime: + final mode = (valueRaw as TwentyFourHourTimeMode); + // TODO(server-future) allow localeDefault for servers that support it + assert(mode != TwentyFourHourTimeMode.localeDefault); + value = mode.toJson(); + case UserSettingName.displayEmojiReactionUsers: + value = valueRaw as bool; + case UserSettingName.emojiset: + value = RawParameter((valueRaw as Emojiset).toJson()); + case UserSettingName.presenceEnabled: + value = valueRaw as bool; + } + params[name.toJson()] = value; + } + + return connection.patch('updateSettings', (_) {}, 'settings', params); +} 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/basic.dart b/lib/basic.dart new file mode 100644 index 0000000000..28d27df999 --- /dev/null +++ b/lib/basic.dart @@ -0,0 +1,68 @@ + +/// Either a value, or the absence of a value. +/// +/// An `Option` is either an `OptionSome` representing a `T` value, +/// or an `OptionNone` representing the absence of a value. +/// +/// When `T` is non-nullable, this is the same information that is +/// normally represented as a `T?`. +/// This class is useful when T is nullable (or might be nullable). +/// In that case `null` is already a T value, +/// and so can't also be used to represent the absence of a T value, +/// but `OptionNone()` is a different value from `OptionSome(null)`. +/// +/// This interface is small because members are added lazily when needed. +/// If adding another member, consider borrowing the naming from Rust: +/// https://doc.rust-lang.org/std/option/enum.Option.html +sealed class Option { + const Option(); + + /// The value contained in this option, if any; else the given value. + T or(T optb); + + /// The value contained in this option, if any; + /// else the value returned by [fn]. + /// + /// [fn] is called only if its return value is needed. + T orElse(T Function() fn); +} + +class OptionNone extends Option { + const OptionNone(); + + @override + T or(T optb) => optb; + + @override + T orElse(T Function() fn) => fn(); + + @override + bool operator ==(Object other) => other is OptionNone; + + @override + int get hashCode => 'OptionNone'.hashCode; + + @override + String toString() => 'OptionNone'; +} + +class OptionSome extends Option { + const OptionSome(this.value); + + final T value; + + @override + T or(T optb) => value; + + @override + T orElse(T Function() fn) => value; + + @override + bool operator ==(Object other) => other is OptionSome && value == other.value; + + @override + int get hashCode => Object.hash('OptionSome', value); + + @override + String toString() => 'OptionSome($value)'; +} diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index fc6f42cd64..5c96cb6a00 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -198,14 +198,14 @@ class ExampleVerticalDouble extends StatelessWidget { } } -//////////////////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////////////////// // // That's it! // // The rest of this file is boring infrastructure for navigating to the // different examples, and for having some content to put inside them. // -//////////////////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////////////////// class WideHeader extends StatelessWidget { const WideHeader({super.key, required this.i}); diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index ecb0eee16a..9668b50f26 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -8,12 +8,16 @@ 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_fr.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 @@ -105,12 +109,26 @@ abstract class ZulipLocalizations { Locale('ar'), Locale('de'), Locale('en', 'GB'), + Locale('fr'), + 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. @@ -137,6 +155,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: @@ -239,6 +281,12 @@ abstract class ZulipLocalizations { /// **'Mark channel as read'** String get actionSheetOptionMarkChannelAsRead; + /// Label for navigating to a channel's topic-list page. + /// + /// In en, this message translates to: + /// **'List of topics'** + String get actionSheetOptionListOfTopics; + /// Label for muting a topic on action sheet. /// /// In en, this message translates to: @@ -305,17 +353,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. /// @@ -625,11 +679,17 @@ abstract class ZulipLocalizations { /// **'Discard the message you’re writing?'** String get discardDraftConfirmationDialogTitle; - /// Message for a confirmation dialog for discarding message text that was typed into the compose box. + /// Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message. /// /// In en, this message translates to: /// **'When you edit a message, the content that was previously in the compose box is discarded.'** - String get discardDraftConfirmationDialogMessage; + String get discardDraftForEditConfirmationDialogMessage; + + /// Message for a confirmation dialog when restoring 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. /// @@ -661,6 +721,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: @@ -751,6 +847,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: @@ -1067,6 +1175,24 @@ abstract class ZulipLocalizations { /// **'Yesterday'** String get yesterday; + /// Label for the 'Invisible mode' switch on the profile page. + /// + /// In en, this message translates to: + /// **'Invisible mode'** + String get invisibleMode; + + /// Error title when turning on invisible mode failed. + /// + /// In en, this message translates to: + /// **'Error turning on invisible mode. Please try again.'** + String get turnOnInvisibleModeErrorTitle; + + /// Error title when turning off invisible mode failed. + /// + /// In en, this message translates to: + /// **'Error turning off invisible mode. Please try again.'** + String get turnOffInvisibleModeErrorTitle; + /// Label for UserRole.owner /// /// In en, this message translates to: @@ -1103,12 +1229,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: @@ -1121,6 +1271,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: @@ -1145,12 +1301,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; + /// Tooltip for button to navigate to topic-list page. + /// + /// In en, this message translates to: + /// **'Topics'** + String get topicsButtonTooltip; + /// Tooltip for button to navigate to a given channel's feed /// /// In en, this message translates to: @@ -1175,12 +1343,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: @@ -1277,6 +1439,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: @@ -1325,6 +1493,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: @@ -1343,11 +1577,11 @@ abstract class ZulipLocalizations { /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification could not be found /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification could not be found.'** + String get errorNotificationOpenAccountNotFound; /// Error title when adding a message reaction fails /// @@ -1379,6 +1613,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: @@ -1414,12 +1660,16 @@ class _ZulipLocalizationsDelegate 'ar', 'de', 'en', + 'fr', + 'it', 'ja', 'nb', 'pl', 'ru', 'sk', + 'sl', 'uk', + 'zh', ].contains(locale.languageCode); @override @@ -1427,6 +1677,14 @@ class _ZulipLocalizationsDelegate } ZulipLocalizations lookupZulipLocalizations(Locale locale) { + // Lookup logic when language+script+country codes are specified. + switch (locale.toString()) { + case 'zh_Hans_CN': + return ZulipLocalizationsZhHansCn(); + case 'zh_Hant_TW': + return ZulipLocalizationsZhHantTw(); + } + // Lookup logic when language+country codes are specified. switch (locale.languageCode) { case 'en': @@ -1447,6 +1705,10 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsDe(); case 'en': return ZulipLocalizationsEn(); + case 'fr': + return ZulipLocalizationsFr(); + case 'it': + return ZulipLocalizationsIt(); case 'ja': return ZulipLocalizationsJa(); case 'nb': @@ -1457,8 +1719,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 98dd9a7af6..110b0dbe24 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'; @@ -315,9 +335,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,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'; @@ -392,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'; @@ -587,6 +635,17 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -605,15 +664,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'; @@ -626,9 +702,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 topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -649,9 +732,6 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -704,6 +784,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -730,6 +813,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'; @@ -741,8 +862,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +880,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 index 08d09bb3c4..f3b1bdad67 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -9,149 +9,174 @@ class ZulipLocalizationsDe extends ZulipLocalizations { ZulipLocalizationsDe([String locale = 'de']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Über Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'App-Version'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'Open-Source-Lizenzen'; @override - String get aboutPageTapToView => 'Tap to view'; + String get aboutPageTapToView => 'Antippen zum Ansehen'; @override - String get chooseAccountPageTitle => 'Choose account'; + String get upgradeWelcomeDialogTitle => 'Willkommen bei der neuen Zulip-App!'; @override - String get settingsPageTitle => 'Settings'; + String get upgradeWelcomeDialogMessage => + 'Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.'; @override - String get switchAccountButton => 'Switch account'; + 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 'Your account at $url is taking a while to load.'; + return 'Dein Account bei $url benötigt einige Zeit zum Laden.'; } @override - String get tryAnotherAccountButton => 'Try another account'; + String get tryAnotherAccountButton => 'Anderen Account ausprobieren'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'Abmelden'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'Abmelden?'; @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.'; + 'Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.'; @override - String get logOutConfirmationDialogConfirmButton => 'Log out'; + String get logOutConfirmationDialogConfirmButton => 'Abmelden'; @override - String get chooseAccountButtonAddAnAccount => 'Add an account'; + String get chooseAccountButtonAddAnAccount => 'Account hinzufügen'; @override - String get profileButtonSendDirectMessage => 'Send direct message'; + String get profileButtonSendDirectMessage => 'Direktnachricht senden'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => + 'Nutzerprofil kann nicht angezeigt werden.'; @override - String get permissionsNeededTitle => 'Permissions needed'; + String get permissionsNeededTitle => 'Berechtigungen erforderlich'; @override - String get permissionsNeededOpenSettings => 'Open settings'; + String get permissionsNeededOpenSettings => 'Einstellungen öffnen'; @override String get permissionsDeniedCameraAccess => - 'To upload an image, please grant Zulip additional permissions in Settings.'; + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.'; @override String get permissionsDeniedReadExternalStorage => - 'To upload files, please grant Zulip additional permissions in Settings.'; + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.'; @override - String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + String get actionSheetOptionMarkChannelAsRead => + 'Kanal als gelesen markieren'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetOptionListOfTopics => 'Themenliste'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String get actionSheetOptionMuteTopic => 'Thema stummschalten'; @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetOptionUnmuteTopic => 'Thema lautschalten'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetOptionFollowTopic => 'Thema folgen'; @override - String get actionSheetOptionResolveTopic => 'Mark as resolved'; + String get actionSheetOptionUnfollowTopic => 'Thema entfolgen'; @override - String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + String get actionSheetOptionResolveTopic => 'Als gelöst markieren'; @override - String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + String get actionSheetOptionUnresolveTopic => 'Als ungelöst markieren'; + + @override + String get errorResolveTopicFailedTitle => + 'Thema konnte nicht als gelöst markiert werden'; @override String get errorUnresolveTopicFailedTitle => - 'Failed to mark topic as unresolved'; + 'Thema konnte nicht als ungelöst markiert werden'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => 'Link zur Nachricht kopieren'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'Ab hier als ungelesen markieren'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionHideMutedMessage => + 'Stummgeschaltete Nachricht wieder ausblenden'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionShare => 'Teilen'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionQuoteMessage => 'Nachricht zitieren'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionStarMessage => 'Nachricht markieren'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionUnstarMessage => 'Markierung aufheben'; @override - String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + String get actionSheetOptionEditMessage => 'Nachricht bearbeiten'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get errorWebAuthOperationalErrorTitle => 'Etwas ist schiefgelaufen'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get errorWebAuthOperationalError => + 'Ein unerwarteter Fehler ist aufgetreten.'; + + @override + String get errorAccountLoggedInTitle => 'Account bereits angemeldet'; @override String errorAccountLoggedIn(String email, String server) { - return 'The account $email at $server is already in your list of accounts.'; + return 'Der Account $email auf $server ist bereits in deiner Account-Liste.'; } @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source.'; + 'Konnte Nachrichtenquelle nicht abrufen.'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'Kopieren fehlgeschlagen'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'Fehler beim Upload der Datei: $filename'; } @override @@ -168,10 +193,16 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num files are', - one: 'File is', + other: '$num Dateien sind', + one: 'Datei ist', ); - return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + 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 @@ -179,56 +210,56 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: 'Files', - one: 'File', + other: 'Dateien', + one: 'Datei', ); - return '$_temp0 too large'; + return '$_temp0 zu groß'; } @override - String get errorLoginInvalidInputTitle => 'Invalid input'; + String get errorLoginInvalidInputTitle => 'Ungültige Eingabe'; @override - String get errorLoginFailedTitle => 'Login failed'; + String get errorLoginFailedTitle => 'Anmeldung fehlgeschlagen'; @override - String get errorMessageNotSent => 'Message not sent'; + String get errorMessageNotSent => 'Nachricht nicht versendet'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Nachricht nicht gespeichert'; @override String errorLoginCouldNotConnect(String url) { - return 'Failed to connect to server:\n$url'; + return 'Verbindung zu Server fehlgeschlagen:\n$url'; } @override - String get errorCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Konnte nicht verbinden'; @override String get errorMessageDoesNotSeemToExist => - 'That message does not seem to exist.'; + 'Diese Nachricht scheint nicht zu existieren.'; @override - String get errorQuotationFailed => 'Quotation failed'; + String get errorQuotationFailed => 'Zitat fehlgeschlagen'; @override String errorServerMessage(String message) { - return 'The server said:\n\n$message'; + return 'Der Server sagte:\n\n$message'; } @override String get errorConnectingToServerShort => - 'Error connecting to Zulip. Retrying…'; + 'Fehler beim Verbinden mit Zulip. Wiederhole…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { - return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + return 'Fehler beim Verbinden mit Zulip auf $serverUrl. Wird wiederholt:\n\n$error'; } @override String get errorHandlingEventTitle => - 'Error handling a Zulip event. Retrying connection…'; + 'Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…'; @override String errorHandlingEventDetails( @@ -236,258 +267,290 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String error, String event, ) { - return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + return 'Fehler beim Verarbeiten eines Zulip-Ereignisses von $serverUrl; Wird wiederholt.\n\nFehler: $error\n\nEreignis: $event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Link kann nicht geöffnet werden'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Link konnte nicht geöffnet werden: $url'; } @override - String get errorMuteTopicFailed => 'Failed to mute topic'; + String get errorMuteTopicFailed => 'Konnte Thema nicht stummschalten'; @override - String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + String get errorUnmuteTopicFailed => 'Konnte Thema nicht lautschalten'; @override - String get errorFollowTopicFailed => 'Failed to follow topic'; + String get errorFollowTopicFailed => 'Konnte Thema nicht folgen'; @override - String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + String get errorUnfollowTopicFailed => 'Konnte Thema nicht entfolgen'; @override - String get errorSharingFailed => 'Sharing failed'; + String get errorSharingFailed => 'Teilen fehlgeschlagen'; @override - String get errorStarMessageFailedTitle => 'Failed to star message'; + String get errorStarMessageFailedTitle => 'Konnte Nachricht nicht markieren'; @override - String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + String get errorUnstarMessageFailedTitle => + 'Konnte Markierung nicht von der Nachricht entfernen'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => + 'Konnte Nachricht nicht bearbeiten'; @override - String get successLinkCopied => 'Link copied'; + String get successLinkCopied => 'Link kopiert'; @override - String get successMessageTextCopied => 'Message text copied'; + String get successMessageTextCopied => 'Nachrichtentext kopiert'; @override - String get successMessageLinkCopied => 'Message link copied'; + String get successMessageLinkCopied => 'Nachrichtenlink kopiert'; @override String get errorBannerDeactivatedDmLabel => - 'You cannot send messages to deactivated users.'; + 'Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.'; @override String get errorBannerCannotPostInChannelLabel => - 'You do not have permission to post in this channel.'; + 'Du hast keine Berechtigung in diesen Kanal zu schreiben.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Nachricht bearbeiten'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Abbrechen'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Speichern'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Kann Nachricht nicht bearbeiten'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'SPEICHERE BEARBEITUNG…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'BEARBEITUNG NICHT GESPEICHERT'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + '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 discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + String get composeBoxAttachFilesTooltip => 'Dateien anhängen'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get composeBoxAttachMediaTooltip => 'Bilder oder Videos anhängen'; @override - String get composeBoxAttachFilesTooltip => 'Attach files'; + String get composeBoxAttachFromCameraTooltip => 'Ein Foto aufnehmen'; @override - String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + String get composeBoxGenericContentHint => 'Eine Nachricht eingeben'; @override - String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + String get newDmSheetComposeButtonLabel => 'Verfassen'; @override - String get composeBoxGenericContentHint => 'Type a message'; + 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 'Message @$user'; + return 'Nachricht an @$user'; } @override - String get composeBoxGroupDmContentHint => 'Message group'; + String get composeBoxGroupDmContentHint => 'Nachricht an Gruppe'; @override - String get composeBoxSelfDmContentHint => 'Jot down something'; + String get composeBoxSelfDmContentHint => 'Schreibe etwas'; @override String composeBoxChannelContentHint(String destination) { - return 'Message $destination'; + return 'Nachricht an $destination'; } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Bereite vor…'; @override - String get composeBoxSendTooltip => 'Send'; + String get composeBoxSendTooltip => 'Senden'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unbekannter Kanal)'; @override - String get composeBoxTopicHintText => 'Topic'; + String get composeBoxTopicHintText => 'Thema'; @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Gib ein Thema ein (leer lassen für “$defaultTopicName”)'; } @override String composeBoxUploadingFilename(String filename) { - return 'Uploading $filename…'; + return 'Lade $filename hoch…'; } @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(lade Nachricht $messageId)'; } @override - String get unknownUserName => '(unknown user)'; + String get unknownUserName => '(Nutzer:in unbekannt)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'DNs mit dir selbst'; @override String messageListGroupYouAndOthers(String others) { - return 'You and $others'; + return 'Du und $others'; } @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'DNs mit $others'; } @override - String get messageListGroupYouWithYourself => 'Messages with yourself'; + 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 => - 'Message length shouldn\'t be greater than 10000 characters.'; + 'Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.'; @override - String get contentValidationErrorEmpty => 'You have nothing to send!'; + String get contentValidationErrorEmpty => 'Du hast nichts zum Senden!'; @override String get contentValidationErrorQuoteAndReplyInProgress => - 'Please wait for the quotation to complete.'; + 'Bitte warte bis das Zitat abgeschlossen ist.'; @override String get contentValidationErrorUploadInProgress => - 'Please wait for the upload to complete.'; + 'Bitte warte bis das Hochladen abgeschlossen ist.'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => 'Abbrechen'; @override - String get dialogContinue => 'Continue'; + String get dialogContinue => 'Fortsetzen'; @override - String get dialogClose => 'Close'; + String get dialogClose => 'Schließen'; @override - String get errorDialogLearnMore => 'Learn more'; + String get errorDialogLearnMore => 'Mehr erfahren'; @override String get errorDialogContinue => 'OK'; @override - String get errorDialogTitle => 'Error'; + String get errorDialogTitle => 'Fehler'; @override String get snackBarDetails => 'Details'; @override - String get lightboxCopyLinkTooltip => 'Copy link'; + String get lightboxCopyLinkTooltip => 'Link kopieren'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Aktuelle Position'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Videolänge'; @override - String get loginPageTitle => 'Log in'; + String get loginPageTitle => 'Anmelden'; @override - String get loginFormSubmitLabel => 'Log in'; + String get loginFormSubmitLabel => 'Anmelden'; @override - String get loginMethodDivider => 'OR'; + String get loginMethodDivider => 'ODER'; @override String signInWithFoo(String method) { - return 'Sign in with $method'; + return 'Anmelden mit $method'; } @override - String get loginAddAnAccountPageTitle => 'Add an account'; + String get loginAddAnAccountPageTitle => 'Account hinzufügen'; @override - String get loginServerUrlLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Deine Zulip Server URL'; @override - String get loginHidePassword => 'Hide password'; + String get loginHidePassword => 'Passwort verstecken'; @override - String get loginEmailLabel => 'Email address'; + String get loginEmailLabel => 'E-Mail-Adresse'; @override - String get loginErrorMissingEmail => 'Please enter your email.'; + String get loginErrorMissingEmail => 'Bitte gib deine E-Mail ein.'; @override - String get loginPasswordLabel => 'Password'; + String get loginPasswordLabel => 'Passwort'; @override - String get loginErrorMissingPassword => 'Please enter your password.'; + String get loginErrorMissingPassword => 'Bitte gib dein Passwort ein.'; @override - String get loginUsernameLabel => 'Username'; + String get loginUsernameLabel => 'Benutzername'; @override - String get loginErrorMissingUsername => 'Please enter your username.'; + String get loginErrorMissingUsername => 'Bitte gib deinen Benutzernamen ein.'; @override String get topicValidationErrorTooLong => - 'Topic length shouldn\'t be greater than 60 characters.'; + 'Länge des Themas sollte 60 Zeichen nicht überschreiten.'; @override String get topicValidationErrorMandatoryButEmpty => - 'Topics are required in this organization.'; + 'Themen sind in dieser Organisation erforderlich.'; @override String errorServerVersionUnsupportedMessage( @@ -495,100 +558,117 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String zulipVersion, String minSupportedZulipVersion, ) { - return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $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 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + return 'Dein Account bei $url konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.'; } @override - String get errorInvalidResponse => 'The server sent an invalid response.'; + String get errorInvalidResponse => + 'Der Server hat eine ungültige Antwort gesendet.'; @override - String get errorNetworkRequestFailed => 'Network request failed'; + String get errorNetworkRequestFailed => 'Netzwerkanfrage fehlgeschlagen'; @override String errorMalformedResponse(int httpStatus) { - return 'Server gave malformed response; HTTP status $httpStatus'; + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus'; } @override String errorMalformedResponseWithCause(int httpStatus, String details) { - return 'Server gave malformed response; HTTP status $httpStatus; $details'; + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus; $details'; } @override String errorRequestFailed(int httpStatus) { - return 'Network request failed: HTTP status $httpStatus'; + return 'Netzwerkanfrage fehlgeschlagen: HTTP Status $httpStatus'; } @override - String get errorVideoPlayerFailed => 'Unable to play the video.'; + String get errorVideoPlayerFailed => + 'Video konnte nicht wiedergegeben werden.'; @override - String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + String get serverUrlValidationErrorEmpty => 'Bitte gib eine URL ein.'; @override - String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + String get serverUrlValidationErrorInvalidUrl => + 'Bitte gib eine gültige URL ein.'; @override String get serverUrlValidationErrorNoUseEmail => - 'Please enter the server URL, not your email.'; + 'Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.'; @override String get serverUrlValidationErrorUnsupportedScheme => - 'The server URL must start with http:// or https://.'; + 'Die Server-URL muss mit http:// oder https:// beginnen.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @override - String get markAllAsReadLabel => 'Mark all messages as read'; + String get markAllAsReadLabel => 'Alle Nachrichten als gelesen markieren'; @override String markAsReadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num Nachrichten', + one: 'Eine Nachricht', ); - return 'Marked $_temp0 as read.'; + return '$_temp0 als gelesen markiert.'; } @override - String get markAsReadInProgress => 'Marking messages as read…'; + String get markAsReadInProgress => 'Nachrichten werden als gelesen markiert…'; @override - String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + String get errorMarkAsReadFailedTitle => + 'Als gelesen markieren fehlgeschlagen'; @override String markAsUnreadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num Nachrichten', + one: 'Eine Nachricht', ); - return 'Marked $_temp0 as unread.'; + return '$_temp0 als ungelesen markiert.'; } @override - String get markAsUnreadInProgress => 'Marking messages as unread…'; + String get markAsUnreadInProgress => + 'Nachrichten werden als ungelesen markiert…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Als ungelesen markieren fehlgeschlagen'; + + @override + String get today => 'Heute'; @override - String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + String get yesterday => 'Gestern'; @override - String get today => 'Today'; + String get invisibleMode => 'Invisible mode'; @override - String get yesterday => 'Yesterday'; + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; @override - String get userRoleOwner => 'Owner'; + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @override + String get userRoleOwner => 'Besitzer'; @override String get userRoleAdministrator => 'Administrator'; @@ -597,170 +677,245 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get userRoleModerator => 'Moderator'; @override - String get userRoleMember => 'Member'; + String get userRoleMember => 'Mitglied'; @override - String get userRoleGuest => 'Guest'; + String get userRoleGuest => 'Gast'; @override - String get userRoleUnknown => 'Unknown'; + String get userRoleUnknown => 'Unbekannt'; @override - String get inboxPageTitle => 'Inbox'; + String get searchMessagesPageTitle => 'Search'; @override - String get recentDmConversationsPageTitle => 'Direct messages'; + String get searchMessagesHintText => 'Search'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get searchMessagesClearButtonTooltip => 'Clear'; @override - String get combinedFeedPageTitle => 'Combined feed'; + String get inboxPageTitle => 'Eingang'; @override - String get mentionsPageTitle => 'Mentions'; + 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 starredMessagesPageTitle => 'Starred messages'; + String get recentDmConversationsPageTitle => 'Direktnachrichten'; @override - String get channelsPageTitle => 'Channels'; + String get recentDmConversationsSectionHeader => 'Direktnachrichten'; @override - String get mainMenuMyProfile => 'My profile'; + String get recentDmConversationsEmptyPlaceholder => + 'Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?'; @override - String get channelFeedButtonTooltip => 'Channel feed'; + 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 topicsButtonTooltip => 'Themen'; + + @override + String get channelFeedButtonTooltip => 'Kanal-Feed'; @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( numOthers, locale: localeName, - other: '$numOthers others', - one: '1 other', + other: '$numOthers weitere', + one: '1 weitere:n', ); - return '$senderFullName to you and $_temp0'; + return '$senderFullName an dich und $_temp0'; } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Angeheftet'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Nicht angeheftet'; @override - String get subscriptionListNoChannels => 'No channels found'; + String get notifSelfUser => 'Du'; @override - String get notifSelfUser => 'You'; - - @override - String get reactedEmojiSelfUser => 'You'; + String get reactedEmojiSelfUser => 'Du'; @override String onePersonTyping(String typist) { - return '$typist is typing…'; + return '$typist tippt…'; } @override String twoPeopleTyping(String typist, String otherTypist) { - return '$typist and $otherTypist are typing…'; + return '$typist und $otherTypist tippen…'; } @override - String get manyPeopleTyping => 'Several people are typing…'; + String get manyPeopleTyping => 'Mehrere Leute tippen…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'alle'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'jeder'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'Kanal'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'Stream'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'Thema'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Kanal benachrichtigen'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Stream benachrichtigen'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Empfänger benachrichtigen'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Thema benachrichtigen'; @override - String get messageIsEditedLabel => 'EDITED'; + String get messageIsEditedLabel => 'BEARBEITET'; @override - String get messageIsMovedLabel => 'MOVED'; + String get messageIsMovedLabel => 'VERSCHOBEN'; + + @override + String get messageNotSentLabel => 'NACHRICHT NICHT GESENDET'; @override String pollVoterNames(String voterNames) { - return '($voterNames)'; + return '$voterNames'; } @override - String get themeSettingTitle => 'THEME'; + String get themeSettingTitle => 'THEMA'; @override - String get themeSettingDark => 'Dark'; + String get themeSettingDark => 'Dunkel'; @override - String get themeSettingLight => 'Light'; + String get themeSettingLight => 'Hell'; @override String get themeSettingSystem => 'System'; @override - String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + 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 pollWidgetQuestionMissing => 'No question.'; + 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 pollWidgetOptionsMissing => 'This poll has no options yet.'; + String get initialAnchorSettingFirstUnreadAlways => + 'Erste ungelesene Nachricht'; @override - String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + 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 => - '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.'; + '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 errorNotificationOpenTitle => 'Failed to open notification'; + String get errorReactionAddingFailedTitle => + 'Hinzufügen der Reaktion fehlgeschlagen'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorReactionRemovingFailedTitle => + 'Entfernen der Reaktion fehlgeschlagen'; @override - String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + String get emojiReactionsMore => 'mehr'; @override - String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + String get emojiPickerSearchEmoji => 'Emoji suchen'; @override - String get emojiReactionsMore => 'more'; + String get noEarlierMessages => 'Keine früheren Nachrichten'; @override - String get emojiPickerSearchEmoji => 'Search emoji'; + String get revealButtonLabel => + 'Nachricht für stummgeschalteten Absender anzeigen'; @override - String get noEarlierMessages => 'No earlier messages'; + String get mutedUser => 'Stummgeschaltete:r Nutzer:in'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get scrollToBottomTooltip => 'Nach unten Scrollen'; @override String get appVersionUnknownPlaceholder => '(…)'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 105162429b..f99c386087 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'; @@ -315,9 +335,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,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'; @@ -392,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'; @@ -587,6 +635,17 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -605,15 +664,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'; @@ -626,9 +702,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 topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -649,9 +732,6 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -704,6 +784,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -730,6 +813,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'; @@ -741,8 +862,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +880,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'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart new file mode 100644 index 0000000000..cbc18b6d35 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -0,0 +1,897 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class ZulipLocalizationsFr extends ZulipLocalizations { + ZulipLocalizationsFr([String locale = 'fr']) : 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 invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @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 topicsButtonTooltip => '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'; +} diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart new file mode 100644 index 0000000000..2d7d35e23e --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -0,0 +1,919 @@ +// 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 invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @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 topicsButtonTooltip => '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 74a2d4bedb..edf5c759f9 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -9,25 +9,38 @@ class ZulipLocalizationsJa extends ZulipLocalizations { ZulipLocalizationsJa([String locale = 'ja']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Zulipについて'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'アプリのバージョン'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'オープンソースライセンス'; @override - String get aboutPageTapToView => 'Tap to view'; + 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 => 'Settings'; + String get settingsPageTitle => '設定'; @override - String get switchAccountButton => 'Switch account'; + String get switchAccountButton => 'アカウントを切り替える'; @override String tryAnotherAccountMessage(Object url) { @@ -38,10 +51,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get tryAnotherAccountButton => 'Try another account'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'ログアウト'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'ログアウトしますか?'; @override String get logOutConfirmationDialogMessage => @@ -74,68 +87,73 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'To upload files, please grant Zulip additional permissions in Settings.'; @override - String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + String get actionSheetOptionMarkChannelAsRead => 'チャンネルを既読にする'; + + @override + String get actionSheetOptionListOfTopics => 'トピック一覧'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetOptionMuteTopic => 'トピックをミュート'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String get actionSheetOptionUnmuteTopic => 'トピックのミュートを解除'; @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetOptionFollowTopic => 'トピックをフォロー'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetOptionUnfollowTopic => 'トピックのフォローを解除'; @override - String get actionSheetOptionResolveTopic => 'Mark as resolved'; + String get actionSheetOptionResolveTopic => '解決済みにする'; @override - String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + String get actionSheetOptionUnresolveTopic => '未解決にする'; @override - String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + String get errorResolveTopicFailedTitle => 'トピックを解決済みにできませんでした'; @override - String get errorUnresolveTopicFailedTitle => - 'Failed to mark topic as unresolved'; + String get errorUnresolveTopicFailedTitle => 'トピックを未解決にできませんでした'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => 'メッセージへのリンクをコピー'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'ここから未読にする'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionHideMutedMessage => 'ミュートしたメッセージを再び非表示にする'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionShare => '共有'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionQuoteMessage => 'メッセージを引用'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionStarMessage => 'メッセージにスターを付ける'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionUnstarMessage => 'メッセージのスターを外す'; @override - String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + String get actionSheetOptionEditMessage => 'メッセージを編集'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get errorWebAuthOperationalErrorTitle => '問題が発生しました'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get errorWebAuthOperationalError => '予期しないエラーが発生しました。'; + + @override + String get errorAccountLoggedInTitle => 'このアカウントはすでにログインしています'; @override String errorAccountLoggedIn(String email, String server) { @@ -143,15 +161,14 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => - 'Could not fetch message source.'; + String get errorCouldNotFetchMessageSource => 'メッセージのソースを取得できませんでした。'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'コピーに失敗しました'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'ファイルのアップロードに失敗しました: $filename'; } @override @@ -315,9 +332,13 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,6 +354,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'; @@ -392,6 +431,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'; @@ -587,6 +632,17 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'オーナー'; @@ -605,15 +661,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'; @@ -626,9 +699,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 topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -649,9 +729,6 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -704,6 +781,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -730,6 +810,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'; @@ -741,8 +859,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +877,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 02913278b8..0568bc0ae7 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'; @@ -315,9 +335,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,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'; @@ -392,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'; @@ -587,6 +635,17 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -605,15 +664,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'; @@ -626,9 +702,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 topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -649,9 +732,6 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @@ -704,6 +784,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -730,6 +813,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'; @@ -741,8 +862,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -759,6 +880,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 26b4b7e306..c96ab24679 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ą'; @@ -322,9 +343,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Czy chcesz przerwać szykowanie wpisu?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @@ -340,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'; @@ -399,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'; @@ -596,6 +646,17 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get yesterday => 'Wczoraj'; + @override + String get invisibleMode => 'Tryb ukrycia'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Problem z włączeniem trybu ukrycia. Spróbuj ponownie.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.'; + @override String get userRoleOwner => 'Właściciel'; @@ -614,15 +675,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'; @@ -635,9 +713,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 topicsButtonTooltip => 'Wątki'; + @override String get channelFeedButtonTooltip => 'Strumień kanału'; @@ -658,9 +742,6 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Odpięte'; - @override - String get subscriptionListNoChannels => 'Nie odnaleziono kanałów'; - @override String get notifSelfUser => 'Ty'; @@ -713,6 +794,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)'; @@ -739,6 +823,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'; @@ -751,8 +874,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Otwieranie powiadomienia bez powodzenia'; @override - String get errorNotificationOpenAccountMissing => - 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get errorNotificationOpenAccountNotFound => + 'Nie odnaleziono konta powiązanego z tym powiadomieniem.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; @@ -770,6 +893,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 5d8899290d..be5de60e97 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 => 'Отметить сообщение'; @@ -128,7 +149,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Редактировать сообщение'; @override String get actionSheetOptionMarkTopicAsRead => @@ -150,7 +171,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не удалось извлечь источник сообщения'; + 'Не удалось извлечь источник сообщения.'; @override String get errorCopyingFailed => 'Сбой копирования'; @@ -201,7 +222,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorMessageNotSent => 'Сообщение не отправлено'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Сообщение не сохранено'; @override String errorLoginCouldNotConnect(String url) { @@ -277,7 +298,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Не удалось снять отметку с сообщения'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; @override String get successLinkCopied => 'Ссылка скопирована'; @@ -297,37 +318,41 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'У вас нет права писать в этом канале.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Редактирование сообщения'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Отмена'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Сохранить'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Редактирование недоступно'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Редактирование уже выполняется. Дождитесь завершения.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ЗАПИСЬ ПРАВОК…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ СОХРАНЕНЫ'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Отказаться от написанного сообщения?'; @override - String get discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + String get discardDraftForEditConfirmationDialogMessage => + 'При изменении сообщения текст из поля для редактирования удаляется.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'При восстановлении неотправленного сообщения содержимое поля редактирования очищается.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -341,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'; @@ -358,7 +401,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Подготовка…'; @override String get composeBoxSendTooltip => 'Отправить'; @@ -371,7 +414,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Укажите тему (или оставьте “$defaultTopicName”)'; } @override @@ -400,6 +443,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'ЛС с $others'; } + @override + String get emptyMessageList => 'Здесь нет сообщений.'; + + @override + String get emptyMessageListSearch => 'Ничего не найдено.'; + @override String get messageListGroupYouWithYourself => 'Сообщения с собой'; @@ -514,7 +563,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; + String get errorInvalidResponse => 'Сервер отправил недопустимый ответ.'; @override String get errorNetworkRequestFailed => 'Сбой сетевого запроса'; @@ -535,7 +584,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Не удается воспроизвести видео'; + String get errorVideoPlayerFailed => 'Не удается воспроизвести видео.'; @override String get serverUrlValidationErrorEmpty => 'Пожалуйста, введите URL-адрес.'; @@ -600,6 +649,17 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get yesterday => 'Вчера'; + @override + String get invisibleMode => 'Режим невидимости'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Не удалось включить режим невидимости. Повторите попытку позже.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Не удалось отключить режим невидимости. Повторите попытку позже.'; + @override String get userRoleOwner => 'Владелец'; @@ -618,15 +678,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 => 'Объединенная лента'; @@ -639,9 +716,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get channelsEmptyPlaceholder => + 'Вы еще не подписаны ни на один канал.'; + @override String get mainMenuMyProfile => 'Мой профиль'; + @override + String get topicsButtonTooltip => 'Темы'; + @override String get channelFeedButtonTooltip => 'Лента канала'; @@ -662,9 +746,6 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Откреплены'; - @override - String get subscriptionListNoChannels => 'Каналы не найдены'; - @override String get notifSelfUser => 'Вы'; @@ -717,6 +798,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageNotSentLabel => 'СООБЩЕНИЕ НЕ ОТПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -743,6 +827,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 => 'Экспериментальные функции'; @@ -755,8 +879,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => - 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => + 'Учетная запись, связанная с этим уведомлением, не найдена.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; @@ -773,6 +897,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 3ff534eca5..33b4465eb6 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'; @@ -315,9 +335,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -333,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'; @@ -392,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'; @@ -589,6 +637,17 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get yesterday => 'Včera'; + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Majiteľ'; @@ -607,15 +666,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'; @@ -628,9 +704,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 topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -651,9 +734,6 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'Ty'; @@ -706,6 +786,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -732,6 +815,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'; @@ -743,8 +864,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; @@ -761,6 +882,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..8d587b9085 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -0,0 +1,926 @@ +// 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 invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @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 topicsButtonTooltip => '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 94fee8825a..3f8ddd650a 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,11 +132,15 @@ 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 => 'Вибрати повідомлення'; @@ -129,7 +150,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Зняти позначку зірки з повідомлення'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Редагувати повідомлення'; @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; @@ -150,7 +171,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не вдалося отримати джерело повідомлення'; + 'Не вдалося отримати джерело повідомлення.'; @override String get errorCopyingFailed => 'Помилка копіювання'; @@ -201,7 +222,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorMessageNotSent => 'Повідомлення не надіслано'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Повідомлення не збережено'; @override String errorLoginCouldNotConnect(String url) { @@ -277,7 +298,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Не вдалося зняти позначку зірки з повідомлення'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => + 'Не вдалося редагувати повідомлення'; @override String get successLinkCopied => 'Посилання скопійовано'; @@ -298,37 +320,41 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Ви не маєте дозволу на публікацію в цьому каналі.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Редагування повідомлення'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Відміна'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Зберегти'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Неможливо редагувати повідомлення'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Редагування уже виконується. Дочекайтеся його завершення.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ЗБЕРЕЖЕННЯ ПРАВОК…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ ЗБЕРЕЖЕНІ'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Відмовитися від написаного повідомлення?'; @override - String get discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + String get discardDraftForEditConfirmationDialogMessage => + 'При редагуванні повідомлення, текст з поля для редагування видаляється.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'При відновленні невідправленого повідомлення, вміст поля редагування очищається.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Скинути'; @override String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; @@ -342,6 +368,24 @@ 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'; @@ -359,7 +403,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Підготовка…'; @override String get composeBoxSendTooltip => 'Надіслати'; @@ -372,7 +416,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Вкажіть тему (або залиште “$defaultTopicName”)'; } @override @@ -401,6 +445,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Особисті повідомлення з $others'; } + @override + String get emptyMessageList => 'Тут немає повідомлень.'; + + @override + String get emptyMessageListSearch => 'Немає результатів пошуку.'; + @override String get messageListGroupYouWithYourself => 'Повідомлення з собою'; @@ -514,7 +564,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь'; + String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь.'; @override String get errorNetworkRequestFailed => 'Помилка запиту мережі'; @@ -535,7 +585,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Неможливо відтворити відео'; + String get errorVideoPlayerFailed => 'Неможливо відтворити відео.'; @override String get serverUrlValidationErrorEmpty => 'Будь ласка, введіть URL.'; @@ -599,6 +649,17 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get yesterday => 'Учора'; + @override + String get invisibleMode => 'Невидимий режим'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Помилка ввімкнення режиму невидимості. Спробуйте ще раз.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Помилка вимкнення режиму невидимості. Спробуйте ще раз.'; + @override String get userRoleOwner => 'Власник'; @@ -617,15 +678,32 @@ class ZulipLocalizationsUk 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 => 'Об\'єднана стрічка'; @@ -638,9 +716,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get channelsPageTitle => 'Канали'; + @override + String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; + @override String get mainMenuMyProfile => 'Мій профіль'; + @override + String get topicsButtonTooltip => 'Теми'; + @override String get channelFeedButtonTooltip => 'Стрічка каналу'; @@ -661,9 +745,6 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Відкріплені'; - @override - String get subscriptionListNoChannels => 'Канали не знайдено'; - @override String get notifSelfUser => 'Ви'; @@ -716,6 +797,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; + @override + String get messageNotSentLabel => 'ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -744,6 +828,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 => 'Експериментальні функції'; @@ -755,8 +879,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; @override - String get errorNotificationOpenAccountMissing => - 'Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.'; + String get errorNotificationOpenAccountNotFound => + 'Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; @@ -773,6 +897,12 @@ 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..017e7487cf --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -0,0 +1,2592 @@ +// 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 invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @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 topicsButtonTooltip => '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 topicsButtonTooltip => '话题'; + + @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 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 個檔案', + one: '檔案', + ); + return '$_temp0超過伺服器 $maxFileUploadSizeMib MiB 的限制,將不會上傳:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '檔案', + one: '檔案', + ); + return '$_temp0太大'; + } + + @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 '連接 Zulip $serverUrl 時發生錯誤。將重試:\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 emptyMessageList => '這裡沒有訊息。'; + + @override + String get emptyMessageListSearch => '沒有搜尋結果。'; + + @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 => 'OK'; + + @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 Server 為 $zulipVersion,此版本已不受支援。最低支援版本為 Zulip Server $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 => + '伺服器 URL 必須以 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 則訊息', + one: '1 則訊息', + ); + 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 則訊息', + one: '1 則訊息', + ); + return '已標為未讀:$_temp0。'; + } + + @override + String get markAsUnreadInProgress => '正在標註訊息為未讀…'; + + @override + String get errorMarkAsUnreadFailedTitle => '標記為未讀失敗'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get invisibleMode => '隱身模式'; + + @override + String get turnOnInvisibleModeErrorTitle => '啟用隱身模式時發生錯誤。請再試一次。'; + + @override + String get turnOffInvisibleModeErrorTitle => '關閉隱身模式時發生錯誤。請再試一次。'; + + @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 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 => '綜合饋給'; + + @override + String get mentionsPageTitle => '提及'; + + @override + String get starredMessagesPageTitle => '已加星號的訊息'; + + @override + String get channelsPageTitle => '頻道'; + + @override + String get channelsEmptyPlaceholder => '您尚未訂閱任何頻道。'; + + @override + String get mainMenuMyProfile => '我的設定檔'; + + @override + String get topicsButtonTooltip => '話題'; + + @override + String get channelFeedButtonTooltip => '頻道饋給'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 位其他對象', + one: '1 位其他對象、', + ); + 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 => 'channel'; + + @override + String get wildcardMentionStream => '串流'; + + @override + String get wildcardMentionTopic => 'topic'; + + @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這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。'; + + @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'; +} diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index 5f46d154e9..0b777a2964 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.5.0), 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..46c8c22ff4 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,210 @@ +// Autogenerated from Pigeon (v25.5.0), 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/model/autocomplete.dart b/lib/model/autocomplete.dart index 034199521d..fe344189de 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -12,6 +12,7 @@ import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; extension ComposeContentAutocomplete on ComposeContentController { AutocompleteIntent? autocompleteIntent() { @@ -486,6 +487,7 @@ class MentionAutocompleteView extends AutocompleteView _compareByRelevance(userA, userB, @@ -556,7 +558,6 @@ class MentionAutocompleteView extends AutocompleteView 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 @@ -469,6 +485,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..7a10d52f6b 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -1,8 +1,12 @@ +import 'dart:collection'; + import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'store.dart'; +import 'user.dart'; /// The portion of [PerAccountStore] for channels, topics, and stuff about them. /// @@ -10,7 +14,7 @@ import '../api/model/model.dart'; /// implementation of [PerAccountStore], to avoid circularity. /// /// The data structures described here are implemented at [ChannelStoreImpl]. -mixin ChannelStore { +mixin ChannelStore on UserStore { /// All known channels/streams, indexed by [ZulipStream.streamId]. /// /// The same [ZulipStream] objects also appear in [streamsByName]. @@ -41,6 +45,8 @@ mixin ChannelStore { /// /// For policies directly applicable in the UI, see /// [isTopicVisibleInStream] and [isTopicVisible]. + /// + /// Topics are treated case-insensitively; see [TopicName.isSameAs]. UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic); /// The raw data structure underlying [topicVisibilityPolicy]. @@ -69,10 +75,10 @@ mixin ChannelStore { /// Whether the given event will change the result of [isTopicVisibleInStream] /// for its stream and topic, compared to the current state. - VisibilityEffect willChangeIfTopicVisibleInStream(UserTopicEvent event) { + UserTopicVisibilityEffect willChangeIfTopicVisibleInStream(UserTopicEvent event) { final streamId = event.streamId; final topic = event.topicName; - return VisibilityEffect._fromBeforeAfter( + return UserTopicVisibilityEffect._fromBeforeAfter( _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic)), _isTopicVisibleInStream(event.visibilityPolicy)); } @@ -106,10 +112,10 @@ mixin ChannelStore { /// Whether the given event will change the result of [isTopicVisible] /// for its stream and topic, compared to the current state. - VisibilityEffect willChangeIfTopicVisible(UserTopicEvent event) { + UserTopicVisibilityEffect willChangeIfTopicVisible(UserTopicEvent event) { final streamId = event.streamId; final topic = event.topicName; - return VisibilityEffect._fromBeforeAfter( + return UserTopicVisibilityEffect._fromBeforeAfter( _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic)), _isTopicVisible(streamId, event.visibilityPolicy)); } @@ -132,12 +138,37 @@ mixin ChannelStore { return true; } } + + bool hasPostingPermission({ + required ZulipStream inChannel, + required User user, + required DateTime byDate, + }) { + final role = user.role; + // We let the users with [unknown] role to send the message, then the server + // will decide to accept it or not based on its actual role. + if (role == UserRole.unknown) return true; + + switch (inChannel.channelPostPolicy) { + case ChannelPostPolicy.any: return true; + case ChannelPostPolicy.fullMembers: { + if (!role.isAtLeast(UserRole.member)) return false; + if (role == UserRole.member) { + return hasPassedWaitingPeriod(user, byDate: byDate); + } + return true; + } + case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); + case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); + case ChannelPostPolicy.unknown: return true; + } + } } /// Whether and how a given [UserTopicEvent] will affect the results /// that [ChannelStore.isTopicVisible] or [ChannelStore.isTopicVisibleInStream] /// would give for some messages. -enum VisibilityEffect { +enum UserTopicVisibilityEffect { /// The event will have no effect on the visibility results. none, @@ -147,22 +178,47 @@ enum VisibilityEffect { /// The event will change some visibility results from false to true. unmuted; - factory VisibilityEffect._fromBeforeAfter(bool before, bool after) { + factory UserTopicVisibilityEffect._fromBeforeAfter(bool before, bool after) { return switch ((before, after)) { - (false, true) => VisibilityEffect.unmuted, - (true, false) => VisibilityEffect.muted, - _ => VisibilityEffect.none, + (false, true) => UserTopicVisibilityEffect.unmuted, + (true, false) => UserTopicVisibilityEffect.muted, + _ => UserTopicVisibilityEffect.none, }; } } +mixin ProxyChannelStore on ChannelStore { + @protected + ChannelStore get channelStore; + + @override + Map get streams => channelStore.streams; + + @override + Map get streamsByName => channelStore.streamsByName; + + @override + Map get subscriptions => channelStore.subscriptions; + + @override + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => + channelStore.topicVisibilityPolicy(streamId, topic); + + @override + Map> get debugTopicVisibility => + channelStore.debugTopicVisibility; +} + /// The implementation of [ChannelStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [ChannelStore] which describes its interface. -class ChannelStoreImpl with ChannelStore { - factory ChannelStoreImpl({required InitialSnapshot initialSnapshot}) { +class ChannelStoreImpl extends HasUserStore with ChannelStore { + factory ChannelStoreImpl({ + required UserStore users, + required InitialSnapshot initialSnapshot, + }) { final subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( (subscription) => MapEntry(subscription.streamId, subscription))); @@ -171,17 +227,18 @@ class ChannelStoreImpl with ChannelStore { streams.putIfAbsent(stream.streamId, () => stream); } - final topicVisibility = >{}; + final topicVisibility = >{}; for (final item in initialSnapshot.userTopics ?? const []) { if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) { // Not a value we expect. Keep it out of our data structures. // TODO(log) continue; } - final forStream = topicVisibility.putIfAbsent(item.streamId, () => {}); + final forStream = topicVisibility.putIfAbsent(item.streamId, () => makeTopicKeyedMap()); forStream[item.topicName] = item.visibilityPolicy; } return ChannelStoreImpl._( + users: users, streams: streams, streamsByName: streams.map((_, stream) => MapEntry(stream.name, stream)), subscriptions: subscriptions, @@ -190,6 +247,7 @@ class ChannelStoreImpl with ChannelStore { } ChannelStoreImpl._({ + required super.users, required this.streams, required this.streamsByName, required this.subscriptions, @@ -204,9 +262,9 @@ class ChannelStoreImpl with ChannelStore { final Map subscriptions; @override - Map> get debugTopicVisibility => topicVisibility; + Map> get debugTopicVisibility => topicVisibility; - final Map> topicVisibility; + final Map> topicVisibility; @override UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) { @@ -322,7 +380,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 +412,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. @@ -365,8 +422,26 @@ class ChannelStoreImpl with ChannelStore { topicVisibility.remove(event.streamId); } } else { - final forStream = topicVisibility.putIfAbsent(event.streamId, () => {}); + final forStream = topicVisibility.putIfAbsent(event.streamId, () => makeTopicKeyedMap()); forStream[event.topicName] = visibilityPolicy; } } } + +/// A [Map] with [TopicName] keys and [V] values. +/// +/// When one of these is created by [makeTopicKeyedMap], +/// key equality is done case-insensitively; see there. +/// +/// This type should only be used for maps created by [makeTopicKeyedMap]. +/// It would be nice to enforce that. +typedef TopicKeyedMap = Map; + +/// Make a case-insensitive, case-preserving [TopicName]-keyed [LinkedHashMap]. +/// +/// The equality function is [TopicName.isSameAs], +/// and the hash code is [String.hashCode] of [TopicName.canonicalize]. +TopicKeyedMap makeTopicKeyedMap() => LinkedHashMap( + equals: (a, b) => a.isSameAs(b), + hashCode: (k) => k.canonicalize().hashCode, +); 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..4413857173 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,11 +341,18 @@ class CodeBlockSpanNode extends ContentNode { } } -abstract class MathNode extends ContentNode { +/// A complete KaTeX math expression within Zulip content, +/// whether block or inline. +/// +/// The content nodes that are descendants of this node +/// will all be of KaTeX-specific types, such as [KatexNode]. +sealed class MathNode extends ContentNode { const MathNode({ super.debugHtmlNode, required this.texSource, required this.nodes, + this.debugHardFailReason, + this.debugSoftFailReason, }); final String texSource; @@ -357,6 +364,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,11 +379,20 @@ abstract class MathNode extends ContentNode { } } -class KatexNode extends ContentNode { - const KatexNode({ - required this.styles, - required this.text, - required this.nodes, +/// A content node that expects a generic KaTeX context from its parent. +/// +/// Each of these will have a [MathNode] as an ancestor. +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +/// A generic KaTeX content node, corresponding to any span in KaTeX HTML +/// that we don't otherwise specially handle. +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ + this.styles = const KatexSpanStyles(), + this.text, + this.nodes, super.debugHtmlNode, }) : assert((text != null) ^ (nodes != null)); @@ -402,11 +421,106 @@ class KatexNode extends ContentNode { } } +/// A KaTeX strut, corresponding to a `span.strut` node in KaTeX HTML. +class KatexStrutNode extends KatexNode { + const KatexStrutNode({ + required this.heightEm, + required this.verticalAlignEm, + super.debugHtmlNode, + }); + + final double heightEm; + final double? verticalAlignEm; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('heightEm', heightEm)); + properties.add(DoubleProperty('verticalAlignEm', verticalAlignEm)); + } +} + +/// A KaTeX "vertical list", corresponding to a `span.vlist-t` in KaTeX HTML. +/// +/// These nodes in KaTeX HTML have a very specific structure. +/// The children of these nodes in our tree correspond in the HTML to +/// certain great-grandchildren (certain `> .vlist-r > .vlist > span`) +/// of the `.vlist-t` node. +class KatexVlistNode extends KatexNode { + const KatexVlistNode({ + required this.rows, + super.debugHtmlNode, + }); + + final List rows; + + @override + List debugDescribeChildren() { + return rows.map((row) => row.toDiagnosticsNode()).toList(); + } +} + +/// An element of a KaTeX "vertical list"; a child of a [KatexVlistNode]. +/// +/// These correspond to certain `.vlist-t > .vlist-r > .vlist > span` nodes +/// in KaTeX HTML. The [KatexVlistNode] parent in our tree +/// corresponds to the `.vlist-t` great-grandparent in the HTML. +class KatexVlistRowNode extends ContentNode { + const KatexVlistRowNode({ + required this.verticalOffsetEm, + required this.node, + super.debugHtmlNode, + }); + + final double verticalOffsetEm; + final KatexSpanNode node; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); + } + + @override + List debugDescribeChildren() { + return [node.toDiagnosticsNode()]; + } +} + +/// A KaTeX node corresponding to negative values for `margin-left` +/// or `margin-right` in the inline CSS style of a KaTeX HTML node. +/// +/// The parser synthesizes these as additional nodes, not corresponding +/// directly to any node in the HTML. +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -876,6 +990,8 @@ class MathInlineNode extends MathNode implements InlineContentNode { super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -900,7 +1016,7 @@ class GlobalTimeNode extends InlineContentNode { } } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// /// Parser for the inline-content subtrees within Zulip content HTML. /// @@ -917,7 +1033,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 +1190,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 +1758,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 +1796,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; } } @@ -1892,3 +2030,9 @@ class _ZulipContentParser { ZulipContent parseContent(String html) { return _ZulipContentParser().parse(html); } + +ZulipMessageContent parseMessageContent(Message message) { + final poll = message.poll; + if (poll != null) return PollContent(poll); + return parseContent(message.content); +} 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 0923fdab79..0b8ae60333 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -122,8 +122,30 @@ mixin EmojiStore { // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; +} + +mixin ProxyEmojiStore on EmojiStore { + @protected + EmojiStore get emojiStore; - void setServerEmojiData(ServerEmojiData data); + @override + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName + }) { + return emojiStore.emojiDisplayFor( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); + } + + @override + Iterable popularEmojiCandidates() => emojiStore.popularEmojiCandidates(); + + @override + Iterable allEmojiCandidates() => emojiStore.allEmojiCandidates(); + + @override + Map>? get debugServerEmojiData => emojiStore.debugServerEmojiData; } /// The implementation of [EmojiStore] that does the work. @@ -374,7 +396,6 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { return _allEmojiCandidates ??= _generateAllCandidates(); } - @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; _popularCandidates = 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..d7d09d5ea2 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,6 @@ +import 'package:collection/collection.dart'; +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; @@ -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.message, + required this.stackTrace, + }); + + final String? message; + 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( + message: e.message, + 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,32 +157,226 @@ 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(List nodes) { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { + throw _KatexHtmlParseError( + node is dom.Element + ? 'unsupported html node: ${node.localName}' + : 'unsupported html node'); + } + + var span = _parseSpan(node); + final negativeRightMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginRightEm?)) + when marginRightEm.isNegative => marginRightEm, + _ => null, + }; + final negativeLeftMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginLeftEm?)) + when marginLeftEm.isNegative => marginLeftEm, + _ => null, + }; + if (span is KatexSpanNode) { + if (negativeRightMarginEm != null || negativeLeftMarginEm != null) { + span = KatexSpanNode( + styles: span.styles.filter( + marginRightEm: negativeRightMarginEm == null, + marginLeftEm: negativeLeftMarginEm == null), + text: span.text, + nodes: span.nodes); + } + } + + if (negativeRightMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeRightMarginEm, + nodes: previousSpans)); + } + + resultSpans.addFirst(span); + + if (negativeLeftMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeLeftMarginEm, + nodes: previousSpans)); + } + } + return resultSpans; + } + + KatexNode _parseSpan(dom.Element element) { + assert(element.localName == 'span'); + // TODO maybe check if the sequence of ancestors matter for spans. + + if (element.className == 'strut') { + return _parseStrut(element); + } + + if (element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2') { + return _parseVlist(element); + } + + return _parseGenericSpan(element); + } + + KatexNode _parseStrut(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'strut'); + if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); + + final styles = _parseInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + final heightEm = _takeStyleEm(styles, 'height'); + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = _takeStyleEm(styles, 'vertical-align'); + if (styles.isNotEmpty) throw _KatexHtmlParseError(); + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm, + debugHtmlNode: kDebugMode ? element : null); } - List _parseChildSpans(dom.Element element) { - return List.unmodifiable(element.nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); + KatexNode _parseVlist(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2'); + final vlistT = element; + if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; + if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + + if (hasTwoVlistR) { + if (vlistT.nodes case [ + _, + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]) && final vlist, + ]), + ]) { + // In the generated HTML the .vlist in second .vlist-r span will have + // a "height" inline style which we ignore, because it doesn't seem + // to have any effect in rendering on the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + // Same as above for the second .vlist-r span, .vlist span in first + // .vlist-r span will have "height" inline style which we ignore, + // because it doesn't seem to have any effect in rendering on + // the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { + throw _KatexHtmlParseError(); + } + + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } + + final inlineStyles = _parseInlineStyles(innerSpan); + if (inlineStyles == null) throw _KatexHtmlParseError(); + final marginLeftEm = _takeStyleEm(inlineStyles, 'margin-left'); + final marginLeftIsNegative = marginLeftEm?.isNegative ?? false; + final marginRightEm = _takeStyleEm(inlineStyles, 'margin-right'); + if (marginRightEm?.isNegative ?? false) throw _KatexHtmlParseError(); + final styles = KatexSpanStyles( + marginLeftEm: marginLeftIsNegative ? null : marginLeftEm, + marginRightEm: marginRightEm, + ); + final topEm = _takeStyleEm(inlineStyles, 'top'); + if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); + + final pstrutStyles = _parseInlineStyles(pstrutSpan); + if (pstrutStyles == null) throw _KatexHtmlParseError(); + final pstrutHeightEm = _takeStyleEm(pstrutStyles, 'height'); + if (pstrutHeightEm == null) throw _KatexHtmlParseError(); + if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); + + KatexSpanNode child = KatexSpanNode( + styles: styles, + nodes: _parseChildSpans(otherSpans)); + + if (marginLeftIsNegative) { + child = KatexSpanNode( + nodes: [KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm!, + nodes: [child])]); + } + + rows.add(KatexVlistRowNode( + verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: child)); + } else { + throw _KatexHtmlParseError(); + } + } + + // TODO(#1716) Handle styling for .vlist-t2 spans + return KatexVlistNode( + rows: rows, + debugHtmlNode: kDebugMode ? element : null, + ); } else { - throw KatexHtmlParseError(); + throw _KatexHtmlParseError(); } - })); + } else { + throw _KatexHtmlParseError(); + } } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); static final _sizeClassRegExp = RegExp(r'^size(\d\d?)$'); - KatexNode _parseSpan(dom.Element element) { - // TODO maybe check if the sequence of ancestors matter for spans. + KatexNode _parseGenericSpan(dom.Element element) { + assert(element.localName == 'span'); // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. @@ -144,7 +386,9 @@ class _KatexParser { // https://github.com/KaTeX/KaTeX/blob/2fe1941b/src/styles/katex.scss // A copy of class definition (where possible) is accompanied in a comment // with each case statement to keep track of updates. - final spanClasses = List.unmodifiable(element.className.split(' ')); + final spanClasses = element.className != '' + ? List.unmodifiable(element.className.split(' ')) + : const []; String? fontFamily; double? fontSizeEm; KatexSpanFontWeight? fontWeight; @@ -161,8 +405,9 @@ class _KatexParser { case 'strut': // .strut { ... } - // Do nothing, it has properties that don't need special handling. - break; + // We expect the 'strut' class to be the only class in a span, + // in which case we handle it separately and emit `KatexStrutNode`. + throw _KatexHtmlParseError(); case 'textbf': // .textbf { font-weight: bold; } @@ -275,20 +520,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 +561,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,54 +575,149 @@ class _KatexParser { 'size4' => 'KaTeX_Size4', 'mult' => // TODO handle nested spans with `.delim-size{1,4}` class. - throw KatexHtmlParseError(), - _ => throw KatexHtmlParseError(), + throw _KatexHtmlParseError('unimplemented CSS class pair: .delimsizing.mult'), + _ => 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 inlineStyles = _parseInlineStyles(element); final styles = KatexSpanStyles( fontFamily: fontFamily, fontSizeEm: fontSizeEm, fontWeight: fontWeight, fontStyle: fontStyle, textAlign: textAlign, + heightEm: _takeStyleEm(inlineStyles, 'height'), + topEm: _takeStyleEm(inlineStyles, 'top'), + marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), + marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), + // TODO handle more CSS properties ); + if (inlineStyles != null && inlineStyles.isNotEmpty) { + for (final property in inlineStyles.keys) { + assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + } + } + // Currently, we expect `top` to only be inside a vlist, and + // we handle that case separately above. + if (styles.topEm != null) { + throw _KatexHtmlParseError('unsupported inline CSS property: top'); + } String? text; List? spans; 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( + return KatexSpanNode( styles: styles, text: text, - nodes: spans); + nodes: spans, + debugHtmlNode: kDebugMode ? element : null); + } + + /// Parse the inline CSS styles from the given element. + /// + /// To interpret the resulting map, consider [_takeStyleEm]. + static Map? _parseInlineStyles(dom.Element element) { + final styleStr = element.attributes['style']; + if (styleStr == null) return null; + + // `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 ruleSet]) { + final result = {}; + for (final declaration in ruleSet.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + result.update(property, ifAbsent: () => expression, + (_) => throw _KatexHtmlParseError( + 'duplicate inline CSS property: $property')); + } else { + throw _KatexHtmlParseError('unexpected shape of inline CSS'); + } + } + return result; + } else { + throw _KatexHtmlParseError(); + } + } + + /// Remove the given property from the given style map, + /// and parse as a length in ems. + /// + /// If the property is present but is not a length in ems, + /// record an error and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + double? _takeStyleEm(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a length in em: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + return null; } } @@ -378,6 +738,21 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { + // TODO(#1674) does height actually appear on generic spans? + // In a corpus, the only occurrences that we don't already handle separately + // (i.e. occurrences other than on struts, vlists, etc) seem to be within + // accents; so after #1674 we might be handling those separately too. + final double? heightEm; + + // We expect `vertical-align` inline style to be only present on a + // `strut` span, for which we emit `KatexStrutNode` separately. + // final double? verticalAlignEm; + + final double? topEm; + + final double? marginRightEm; + final double? marginLeftEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -385,6 +760,10 @@ class KatexSpanStyles { final KatexSpanTextAlign? textAlign; const KatexSpanStyles({ + this.heightEm, + this.topEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -395,6 +774,10 @@ class KatexSpanStyles { @override int get hashCode => Object.hash( 'KatexSpanStyles', + heightEm, + topEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -405,6 +788,10 @@ class KatexSpanStyles { @override bool operator ==(Object other) { return other is KatexSpanStyles && + other.heightEm == heightEm && + other.topEm == topEm && + other.marginRightEm == marginRightEm && + other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -415,6 +802,10 @@ class KatexSpanStyles { @override String toString() { final args = []; + if (heightEm != null) args.add('heightEm: $heightEm'); + if (topEm != null) args.add('topEm: $topEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -422,11 +813,37 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } + + KatexSpanStyles filter({ + bool heightEm = true, + bool verticalAlignEm = true, + bool topEm = true, + bool marginRightEm = true, + bool marginLeftEm = true, + bool fontFamily = true, + bool fontSizeEm = true, + bool fontWeight = true, + bool fontStyle = true, + bool textAlign = true, + }) { + return KatexSpanStyles( + heightEm: heightEm ? this.heightEm : null, + topEm: topEm ? this.topEm : null, + marginRightEm: marginRightEm ? this.marginRightEm : null, + marginLeftEm: marginLeftEm ? this.marginLeftEm : null, + fontFamily: fontFamily ? this.fontFamily : null, + fontSizeEm: fontSizeEm ? this.fontSizeEm : null, + fontWeight: fontWeight ? this.fontWeight : null, + fontStyle: fontStyle ? this.fontStyle : null, + textAlign: textAlign ? this.textAlign : null, + ); + } } -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 2573cfadc6..24788bdd1e 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,12 +1,18 @@ +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 'realm.dart'; import 'store.dart'; const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 @@ -16,27 +22,29 @@ 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, }); - /// Reconcile a batch of just-fetched messages with the store, - /// mutating the list. + /// Remove from [outboxMessages] given the [localMessageId], and return + /// the removed [OutboxMessage]. /// - /// This is called after a [getMessages] request to report the result - /// to the store. + /// The outbox message to be taken must exist. /// - /// The list's length will not change, but some entries may be replaced - /// by a different [Message] object with the same [Message.id]. - /// All [Message] objects in the resulting list will be present in - /// [this.messages]. - void reconcileMessages(List messages); + /// The state of the outbox message must be either [OutboxMessageState.failed] + /// or [OutboxMessageState.waitPeriodExpired]. + OutboxMessage takeOutboxMessage(int localMessageId); /// Whether the current edit request for the given message, if any, has failed. /// @@ -66,6 +74,53 @@ mixin MessageStore { ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); } +mixin ProxyMessageStore on MessageStore { + @protected + MessageStore get messageStore; + + @override + Map get messages => messageStore.messages; + @override + Map get outboxMessages => messageStore.outboxMessages; + @override + void registerMessageList(MessageListView view) => + messageStore.registerMessageList(view); + @override + void unregisterMessageList(MessageListView view) => + messageStore.unregisterMessageList(view); + @override + void markReadFromScroll(Iterable messageIds) => + messageStore.markReadFromScroll(messageIds); + @override + Future sendMessage({required MessageDestination destination, required String content}) { + return messageStore.sendMessage(destination: destination, content: content); + } + @override + OutboxMessage takeOutboxMessage(int localMessageId) => + messageStore.takeOutboxMessage(localMessageId); + + @override + bool? getEditMessageErrorStatus(int messageId) { + return messageStore.getEditMessageErrorStatus(messageId); + } + @override + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) { + return messageStore.editMessage(messageId: messageId, + originalRawContent: originalRawContent, newContent: newContent); + } + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + return messageStore.takeFailedMessageEdit(messageId); + } + + @override + Set get debugMessageListViews => messageStore.debugMessageListViews; +} + class _EditMessageRequestStatus { _EditMessageRequestStatus({ required this.hasError, @@ -78,15 +133,16 @@ class _EditMessageRequestStatus { final String newContent; } -class MessageStoreImpl extends PerAccountStoreBase with MessageStore { - MessageStoreImpl({required super.core}) - // There are no messages in InitialSnapshot, so we don't have - // a use case for initializing MessageStore with nonempty [messages]. - : messages = {}; +class MessageStoreImpl extends HasRealmStore with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.realm}) + : // There are no messages in InitialSnapshot, so we don't have + // a use case for initializing MessageStore with nonempty [messages]. + messages = {}; @override final Map messages; + @override final Set _messageListViews = {}; @override @@ -94,12 +150,16 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override void registerMessageList(MessageListView view) { + assert(!_disposed); final added = _messageListViews.add(view); assert(added); } @override void unregisterMessageList(MessageListView view) { + // TODO: Add `assert(!_disposed);` here once we ensure [PerAccountStore] is + // only disposed after [MessageListView]s with references to it are + // disposed. See [dispose] for details. final removed = _messageListViews.remove(view); assert(removed); } @@ -122,6 +182,9 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + @override + bool _disposed = false; + void dispose() { // Not disposing the [MessageListView]s here, because they are owned by // (i.e., they get [dispose]d by) the [_MessageListState], including in the @@ -137,21 +200,87 @@ 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 - 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, - ); + 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}) { + assert(!_disposed); + if (!debugOutboxEnable) { + return _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true); + } + return _outboxSendMessage(destination: destination, content: content); + } + 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. @@ -169,13 +298,19 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // those events' changes. So we always stick with the version we have. for (int i = 0; i < messages.length; i++) { final message = messages[i]; - messages[i] = this.messages.putIfAbsent(message.id, () => message); + messages[i] = this.messages.putIfAbsent(message.id, () { + message.matchContent = null; + message.matchTopic = null; + return message; + }); } } @override - bool? getEditMessageErrorStatus(int messageId) => - _editMessageRequests[messageId]?.hasError; + bool? getEditMessageErrorStatus(int messageId) { + assert(!_disposed); + return _editMessageRequests[messageId]?.hasError; + } final Map _editMessageRequests = {}; @@ -185,6 +320,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { required String originalRawContent, required String newContent, }) async { + assert(!_disposed); if (_editMessageRequests.containsKey(messageId)) { throw StateError('an edit request is already in progress'); } @@ -202,6 +338,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } catch (e) { // TODO(log) if e is something unexpected + if (_disposed) return; + final status = _editMessageRequests[messageId]; if (status == null) { // The event actually arrived before this request failed @@ -216,6 +354,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); final status = _editMessageRequests.remove(messageId); _notifyMessageListViewsForOneMessage(messageId); if (status == null) { @@ -236,12 +375,20 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + void handleMutedUsersEvent(MutedUsersEvent event) { + for (final view in _messageListViews) { + view.handleMutedUsersEvent(event); + } + } + void handleMessageEvent(MessageEvent event) { // If the message is one we already know about (from a fetch), // clobber it with the one from the event system. // See [fetchedMessages] for reasoning. messages[event.message.id] = event.message; + _handleMessageEventOutbox(event); + for (final view in _messageListViews) { view.handleMessageEvent(event); } @@ -341,6 +488,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); } @@ -435,4 +584,436 @@ 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 HasRealmStore { + 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, + }) async { + assert(!_disposed); + final localMessageId = _nextLocalMessageId++; + assert(!outboxMessages.containsKey(localMessageId)); + + final conversation = switch (destination) { + StreamDestination(:final streamId, :final topic) => + StreamConversation( + streamId, + _processTopicLikeServer(topic), + 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) { + // Processing this just once on creating the outbox message + // allows an uncommon bug, because either of the values + // [zulipFeatureLevel] or [realmEmptyTopicDisplayName] 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. + return processTopicLikeServer(topic); + } + + 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 f2a45b78aa..8e2a183fa7 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,8 +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'; +import 'user.dart'; + +export '../api/route/messages.dart' show Anchor, AnchorCode, NumericAnchor; /// The number of messages to fetch in each request. const kMessageListFetchBatchSize = 100; // TODO tune @@ -63,10 +67,57 @@ class MessageListMessageItem extends MessageListMessageBaseItem { }); } +/// 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)]), + ]); +} + +/// 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, + + /// 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; @@ -74,7 +125,7 @@ mixin _MessageSequence { /// /// This may or may not represent all the message history that /// conceptually belongs in this message list. - /// That information is expressed in [fetched] and [haveOldest]. + /// 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. @@ -94,42 +145,39 @@ mixin _MessageSequence { /// /// 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]. /// @@ -139,14 +187,24 @@ 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 @@ -158,11 +216,14 @@ mixin _MessageSequence { /// 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 and bottom slices of [items] correspond to - /// the top and bottom slices of [messages] respectively. - /// Either the bottom slices of both [items] and [messages] are empty, - /// or the first item in the bottom slice of [items] is a [MessageListMessageItem] - /// for the first message in the bottom slice of [messages]. + /// 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) { @@ -174,26 +235,32 @@ 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 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; } } - ZulipMessageContent _parseMessageContent(Message message) { - final poll = message.poll; - if (poll != null) return PollContent(poll); - return parseContent(message.content); - } - /// Update data derived from the content of the index-th message. void _reparseContent(int index) { final message = messages[index]; - final content = _parseMessageContent(message); + final content = parseMessageContent(message); contents[index] = content; final itemIndex = findItemWithMessageId(message.id); @@ -210,7 +277,7 @@ mixin _MessageSequence { void _addMessage(Message message) { assert(contents.length == messages.length); messages.add(message); - contents.add(_parseMessageContent(message)); + contents.add(parseMessageContent(message)); assert(contents.length == messages.length); _processMessage(messages.length - 1); } @@ -289,7 +356,7 @@ mixin _MessageSequence { assert(contents.length == messages.length); messages.insertAll(index, toInsert); contents.insertAll(index, toInsert.map( - (message) => _parseMessageContent(message))); + (message) => parseMessageContent(message))); assert(contents.length == messages.length); if (index <= middleMessage) { middleMessage += messages.length - oldLength; @@ -297,16 +364,52 @@ mixin _MessageSequence { _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(); middleMessage = 0; - _fetched = false; + outboxMessages.clear(); _haveOldest = false; - _fetchingOlder = false; - _fetchOlderCoolingDown = false; - _fetchOlderCooldownBackoffMachine = null; + _haveNewest = false; + _status = FetchingStatus.unstarted; + _fetchBackoffMachine = null; contents.clear(); items.clear(); middleItem = 0; @@ -316,29 +419,40 @@ mixin _MessageSequence { void _recompute() { assert(contents.length == messages.length); contents.clear(); - contents.addAll(messages.map((message) => _parseMessageContent(message))); + contents.addAll(messages.map((message) => parseMessageContent(message))); assert(contents.length == messages.length); _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; @@ -346,21 +460,97 @@ mixin _MessageSequence { items.add(MessageListDateSeparatorItem(message)); canShareSender = false; } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + canShareSender = prevMessageItem.message.senderId == message.senderId; } } - if (index == middleMessage) middleItem = items.length; - items.add(MessageListMessageItem(message, content, - showSender: !canShareSender, isLastInBlock: true)); + final item = buildItem(canShareSender); + assert(identical(item.message, message)); + assert(item.showSender == !canShareSender); + assert(item.isLastInBlock); + if (shouldSetMiddleItem) { + middleItem = items.length; + } + items.add(item); } - /// 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); } if (middleMessage == messages.length) middleItem = items.length; + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } } } @@ -400,13 +590,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; - factory MessageListView.init( - {required PerAccountStore store, required Narrow narrow}) { - final view = MessageListView._(store: store, narrow: narrow); - store.registerMessageList(view); - return view; + final PerAccountStore store; + + /// 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 @@ -415,8 +643,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. @@ -428,10 +663,12 @@ class MessageListView with ChangeNotifier, _MessageSequence { bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message.conversation) { + final conversation = message.conversation; + return switch (conversation) { StreamConversation(:final streamId, :final topic) => store.isTopicVisible(streamId, topic), - DmConversation() => true, + DmConversation() => !store.shouldMuteDmConversation( + DmNarrow.ofConversation(conversation, selfUserId: store.selfUserId)), }; case ChannelNarrow(:final streamId): @@ -442,61 +679,108 @@ class MessageListView with ChangeNotifier, _MessageSequence { case TopicNarrow(): case DmNarrow(): + return true; + case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + if (message.conversation case DmConversation(:final allRecipientIds)) { + return !store.shouldMuteDmConversation(DmNarrow( + allRecipientIds: allRecipientIds, selfUserId: store.selfUserId)); + } return true; } } + /// Whether [_messageVisible] is true for all possible messages. + /// + /// This is useful for an optimization. + bool get _allMessagesVisible { + switch (narrow) { + case CombinedFeedNarrow(): + case ChannelNarrow(): + return false; + + case TopicNarrow(): + case DmNarrow(): + return true; + + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + return false; + } + } + /// Whether this event could affect the result that [_messageVisible] /// would ever have returned for any possible message in this message list. - VisibilityEffect _canAffectVisibility(UserTopicEvent event) { + UserTopicVisibilityEffect _canAffectVisibility(UserTopicEvent event) { switch (narrow) { case CombinedFeedNarrow(): return store.willChangeIfTopicVisible(event); case ChannelNarrow(:final streamId): - if (event.streamId != streamId) return VisibilityEffect.none; + if (event.streamId != streamId) return UserTopicVisibilityEffect.none; return store.willChangeIfTopicVisibleInStream(event); case TopicNarrow(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - return VisibilityEffect.none; + case KeywordSearchNarrow(): + return UserTopicVisibilityEffect.none; } } - /// Whether [_messageVisible] is true for all possible messages. - /// - /// This is useful for an optimization. - bool get _allMessagesVisible { - switch (narrow) { + /// Whether this event could affect the result that [_messageVisible] + /// would ever have returned for any possible message in this message list. + MutedUsersVisibilityEffect _mutedUsersEventCanAffectVisibility(MutedUsersEvent event) { + switch(narrow) { case CombinedFeedNarrow(): - case ChannelNarrow(): - return false; + return store.mightChangeShouldMuteDmConversation(event); + case ChannelNarrow(): case TopicNarrow(): case DmNarrow(): + return MutedUsersVisibilityEffect.none; + case MentionsNarrow(): case StarredMessagesNarrow(): - return true; + case KeywordSearchNarrow(): + return store.mightChangeShouldMuteDmConversation(event); } } + 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; @@ -506,16 +790,29 @@ class MessageListView with ChangeNotifier, _MessageSequence { store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) - // We'll make the bottom slice start at the last visible message, if any. + // 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)) continue; - middleMessage = messages.length; + 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); - // Now [middleMessage] is the last message (the one just added). } - _fetched = true; _haveOldest = result.foundOldest; - 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. @@ -542,26 +839,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; - notifyListeners(); + _setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle); final generation = this.generation; bool hasFetchError = false; try { @@ -569,10 +940,10 @@ 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) { @@ -581,55 +952,136 @@ 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; - notifyListeners(); + _setStatus(FetchingStatus.idle, was: FetchingStatus.backoff); })); } else { - _fetchOlderCooldownBackoffMachine = null; + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchingMore); + _fetchBackoffMachine = null; } - 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: + case UserTopicVisibilityEffect.none: return; - case VisibilityEffect.muted: - if (_removeMessagesWhere((message) => - (message is StreamMessage - && message.streamId == event.streamId - && message.topic == event.topicName))) { + case UserTopicVisibilityEffect.muted: + 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(); } - case VisibilityEffect.unmuted: + case UserTopicVisibilityEffect.unmuted: + // TODO get the newly-unmuted messages from the message store + // For now, we simplify the task by just refetching this message list + // from scratch. + if (fetched) { + _reset(); + notifyListeners(); + fetchInitial(); + } + } + } + + void handleMutedUsersEvent(MutedUsersEvent event) { + switch (_mutedUsersEventCanAffectVisibility(event)) { + case MutedUsersVisibilityEffect.none: + return; + + case MutedUsersVisibilityEffect.muted: + final anyRemoved = _removeMessagesWhere((message) { + if (message is! DmMessage) return false; + final narrow = DmNarrow.ofMessage(message, selfUserId: store.selfUserId); + return store.shouldMuteDmConversation(narrow, event: event); + }); + if (anyRemoved) { + notifyListeners(); + } + + case MutedUsersVisibilityEffect.mixed: + case MutedUsersVisibilityEffect.unmuted: // TODO get the newly-unmuted messages from the message store // For now, we simplify the task by just refetching this message list // from scratch. @@ -650,15 +1102,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(); } @@ -703,9 +1177,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { switch (propagateMode) { case PropagateMode.changeAll: case PropagateMode.changeLater: - narrow = newNarrow; - _reset(); - fetchInitial(); + renarrowAndFetch(newNarrow); case PropagateMode.changeOne: } } @@ -726,12 +1198,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; @@ -777,6 +1256,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..ff4ccfbbc0 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(); @@ -200,11 +203,21 @@ class DmNarrow extends Narrow implements SendableNarrow { required int selfUserId, }) { return DmNarrow( + // TODO should this really be making a copy of `allRecipientIds`? allRecipientIds: List.unmodifiable(message.conversation.allRecipientIds), selfUserId: selfUserId, ); } + factory DmNarrow.ofConversation(DmConversation conversation, { + required int selfUserId, + }) { + return DmNarrow( + allRecipientIds: conversation.allRecipientIds, + selfUserId: selfUserId, + ); + } + /// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations]. factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) { return DmNarrow.withOtherUsers(conversation.userIds, selfUserId: selfUserId); @@ -365,3 +378,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..590c2d3ceb --- /dev/null +++ b/lib/model/presence.dart @@ -0,0 +1,173 @@ +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 'realm.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 HasRealmStore with ChangeNotifier { + Presence({ + required super.realm, + required Map initial, + }) : _map = initial; + + 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/realm.dart b/lib/model/realm.dart new file mode 100644 index 0000000000..41e599580b --- /dev/null +++ b/lib/model/realm.dart @@ -0,0 +1,258 @@ +import 'package:flutter/foundation.dart'; + +import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +/// The portion of [PerAccountStore] for realm settings, server settings, +/// and similar data about the whole realm or server. +/// +/// See also: +/// * [RealmStoreImpl] for the implementation of this that does the work. +/// * [HasRealmStore] for an implementation useful for other substores. +mixin RealmStore on PerAccountStoreBase { + //|////////////////////////////////////////////////////////////// + // Server settings, explicitly so named. + + Duration get serverPresencePingInterval => Duration(seconds: serverPresencePingIntervalSeconds); + int get serverPresencePingIntervalSeconds; + Duration get serverPresenceOfflineThreshold => Duration(seconds: serverPresenceOfflineThresholdSeconds); + int get serverPresenceOfflineThresholdSeconds; + + Duration get serverTypingStartedExpiryPeriod => Duration(milliseconds: serverTypingStartedExpiryPeriodMilliseconds); + int get serverTypingStartedExpiryPeriodMilliseconds; + Duration get serverTypingStoppedWaitPeriod => Duration(milliseconds: serverTypingStoppedWaitPeriodMilliseconds); + int get serverTypingStoppedWaitPeriodMilliseconds; + Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); + int get serverTypingStartedWaitPeriodMilliseconds; + + //|////////////////////////////////////////////////////////////// + // Realm settings. + + //|////////////////////////////// + // Realm settings found in realm/update_dict events: + // https://zulip.com/api/get-events#realm-update_dict + // TODO(#668): update all these realm settings on events. + + bool get realmAllowMessageEditing; + bool get realmMandatoryTopics; + int get maxFileUploadSizeMib; + Duration? get realmMessageContentEditLimit => + realmMessageContentEditLimitSeconds == null ? null + : Duration(seconds: realmMessageContentEditLimitSeconds!); + int? get realmMessageContentEditLimitSeconds; + bool get realmPresenceDisabled; + int get realmWaitingPeriodThreshold; + + //|////////////////////////////// + // Realm settings previously found in realm/update_dict events, + // but now deprecated. + + RealmWildcardMentionPolicy get realmWildcardMentionPolicy; // TODO(#662): replaced by can_mention_many_users_group + + EmailAddressVisibility? get emailAddressVisibility; // TODO: replaced at FL-163 by a user setting + + //|////////////////////////////// + // Realm settings that lack events. + // (Each of these is probably secretly a server setting.) + + /// 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; + + Map get realmDefaultExternalAccounts; + + //|////////////////////////////// + // Realm settings with their own events. + + List get customProfileFields; + + //|////////////////////////////////////////////////////////////// + // Methods that examine the settings. + + /// Process the given 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 the given [TopicName] + /// in a [sendMessage] request. + /// + /// The [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 processTopicLikeServer(TopicName topic) { + final apiName = topic.apiName; + assert(apiName.trim() == apiName); + // 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(apiName.isNotEmpty); + return topic; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && apiName == 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 (apiName == kNoTopicTopic || apiName == 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 topic; + } +} + +mixin ProxyRealmStore on RealmStore { + @protected + RealmStore get realmStore; + + @override + int get serverPresencePingIntervalSeconds => realmStore.serverPresencePingIntervalSeconds; + @override + int get serverPresenceOfflineThresholdSeconds => realmStore.serverPresenceOfflineThresholdSeconds; + @override + int get serverTypingStartedExpiryPeriodMilliseconds => realmStore.serverTypingStartedExpiryPeriodMilliseconds; + @override + int get serverTypingStoppedWaitPeriodMilliseconds => realmStore.serverTypingStoppedWaitPeriodMilliseconds; + @override + int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; + @override + bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; + @override + bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; + @override + int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; + @override + int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; + @override + bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; + @override + int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; + @override + RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + @override + EmailAddressVisibility? get emailAddressVisibility => realmStore.emailAddressVisibility; + @override + String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; + @override + Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; + @override + List get customProfileFields => realmStore.customProfileFields; +} + +/// A base class for [PerAccountStore] substores that need access to [RealmStore] +/// as well as to [CorePerAccountStore]. +abstract class HasRealmStore extends PerAccountStoreBase with RealmStore, ProxyRealmStore { + HasRealmStore({required RealmStore realm}) + : realmStore = realm, super(core: realm.core); + + @protected + @override + final RealmStore realmStore; +} + +/// The implementation of [RealmStore] that does the work. +class RealmStoreImpl extends PerAccountStoreBase with RealmStore { + RealmStoreImpl({ + required super.core, + required InitialSnapshot initialSnapshot, + }) : + serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds, + serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds, + serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, + serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, + serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, + realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, + realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, + maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, + realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, + realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, + realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, + realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, + emailAddressVisibility = initialSnapshot.emailAddressVisibility, + _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, + realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, + customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); + + @override + final int serverPresencePingIntervalSeconds; + @override + final int serverPresenceOfflineThresholdSeconds; + + @override + final int serverTypingStartedExpiryPeriodMilliseconds; + @override + final int serverTypingStoppedWaitPeriodMilliseconds; + @override + final int serverTypingStartedWaitPeriodMilliseconds; + + @override + final bool realmAllowMessageEditing; + @override + final bool realmMandatoryTopics; + @override + final int maxFileUploadSizeMib; + @override + final int? realmMessageContentEditLimitSeconds; + @override + final bool realmPresenceDisabled; + @override + final int realmWaitingPeriodThreshold; + + @override + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + + @override + final EmailAddressVisibility? emailAddressVisibility; + + @override + String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); // TODO(server-10) + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; + + @override + final Map realmDefaultExternalAccounts; + + @override + List customProfileFields; + + 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 + // the order they appear in the array (`custom_profile_fields` in the + // API; our `realmFields` array here.) See chat thread: + // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 + // + // We go on to put at the start of the list any fields that are marked for + // displaying in the "profile summary". (Possibly they should be at the + // start of the list in the first place, but make sure just in case.) + final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); + final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); + return displayFields.followedBy(nonDisplayFields).toList(); + } + + void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { + customProfileFields = _sortCustomProfileFields(event.fields); + } +} diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index a5c4bca778..f8b046641d 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import 'algorithms.dart'; +import 'channel.dart'; /// Tracks the latest messages sent by each user, in each stream and topic. /// @@ -16,7 +17,7 @@ class RecentSenders { // topicSenders[streamId][topic][senderId] = MessageIdTracker @visibleForTesting - final Map>> topicSenders = {}; + final Map>> topicSenders = {}; /// The latest message the given user sent to the given stream, /// or null if no such message is known. @@ -27,6 +28,8 @@ class RecentSenders { /// The latest message the given user sent to the given topic, /// or null if no such message is known. + /// + /// Topics are treated case-insensitively; see [TopicName.isSameAs]. int? latestMessageIdOfSenderInTopic({ required int streamId, required TopicName topic, @@ -53,7 +56,7 @@ class RecentSenders { } for (final entry in messagesByUserInTopic.entries) { final (streamId, topic, senderId) = entry.key; - (((topicSenders[streamId] ??= {})[topic] ??= {}) + (((topicSenders[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= MessageIdTracker()).addAll(entry.value); } } @@ -64,7 +67,7 @@ class RecentSenders { final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; ((streamSenders[streamId] ??= {}) [senderId] ??= MessageIdTracker()).add(messageId); - (((topicSenders[streamId] ??= {})[topic] ??= {}) + (((topicSenders[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= MessageIdTracker()).add(messageId); } 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..4e71224444 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,8 +173,12 @@ 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), + renderKatex(GlobalSettingType.experimentalFeatureFlag, true), /// An experimental flag to enable rendering KaTeX even when some /// errors are encountered. @@ -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 240e3ab4e4..a24550c406 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -14,7 +14,6 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/events.dart'; -import '../api/route/messages.dart'; import '../api/backoff.dart'; import '../api/route/realm.dart'; import '../log.dart'; @@ -25,7 +24,8 @@ import 'database.dart'; import 'emoji.dart'; import 'localizations.dart'; import 'message.dart'; -import 'message_list.dart'; +import 'presence.dart'; +import 'realm.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; @@ -34,6 +34,7 @@ import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; import 'user.dart'; +import 'user_group.dart'; export 'package:drift/drift.dart' show Value; export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException; @@ -361,21 +362,21 @@ class CorePerAccountStore { /// A base class for [PerAccountStore] and its substores, /// with getters providing the items in [CorePerAccountStore]. abstract class PerAccountStoreBase { - PerAccountStoreBase({required CorePerAccountStore core}) - : _core = core; + PerAccountStoreBase({required this.core}); - final CorePerAccountStore _core; + @protected + final CorePerAccountStore core; - //////////////////////////////// + //|////////////////////////////// // Where data comes from in the first place. - GlobalStore get _globalStore => _core._globalStore; + GlobalStore get _globalStore => core._globalStore; - ApiConnection get connection => _core.connection; + ApiConnection get connection => core.connection; - String get queueId => _core.queueId; + String get queueId => core.queueId; - //////////////////////////////// + //|////////////////////////////// // Data attached to the realm or the server. /// Always equal to `account.realmUrl` and `connection.realmUrl`. @@ -392,10 +393,10 @@ abstract class PerAccountStoreBase { String get zulipVersion => account.zulipVersion; - //////////////////////////////// + //|////////////////////////////// // Data attached to the self-account on the realm. - int get accountId => _core.accountId; + int get accountId => core.accountId; /// The [Account] this store belongs to. /// @@ -410,7 +411,7 @@ abstract class PerAccountStoreBase { /// This always equals the [Account.userId] on [account]. /// /// For the corresponding [User] object, see [UserStore.selfUser]. - int get selfUserId => _core.selfUserId; + int get selfUserId => core.selfUserId; } const _tryResolveUrl = tryResolveUrl; @@ -432,7 +433,15 @@ 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, SavedSnippetStore, UserStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with + ChangeNotifier, + UserGroupStore, ProxyUserGroupStore, + RealmStore, ProxyRealmStore, + EmojiStore, ProxyEmojiStore, + SavedSnippetStore, + UserStore, ProxyUserStore, + ChannelStore, ProxyChannelStore, + MessageStore, ProxyMessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -471,42 +480,29 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor accountId: accountId, selfUserId: account.userId, ); - final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); + final realm = RealmStoreImpl(core: core, initialSnapshot: initialSnapshot); + final users = UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot); + final channels = ChannelStoreImpl(users: users, + initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, - realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy, - realmMandatoryTopics: initialSnapshot.realmMandatoryTopics, - realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, - maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, - realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName, - realmAllowMessageEditing: initialSnapshot.realmAllowMessageEditing, - realmMessageContentEditLimitSeconds: initialSnapshot.realmMessageContentEditLimitSeconds, - realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, - customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), - emailAddressVisibility: initialSnapshot.emailAddressVisibility, - emoji: EmojiStoreImpl( - core: core, allRealmEmoji: initialSnapshot.realmEmoji), + groups: UserGroupStoreImpl(core: core, + groups: initialSnapshot.realmUserGroups), + realm: realm, + emoji: EmojiStoreImpl(core: core, + allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, - savedSnippets: SavedSnippetStoreImpl( - core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), - typingNotifier: TypingNotifier( - core: core, - typingStoppedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds), - typingStartedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds), - ), - users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), - typingStatus: TypingStatus(core: core, - typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), - ), + savedSnippets: SavedSnippetStoreImpl(core: core, + savedSnippets: initialSnapshot.savedSnippets ?? []), + typingNotifier: TypingNotifier(realm: realm), + users: users, + typingStatus: TypingStatus(realm: realm), + presence: Presence(realm: realm, + initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(core: core), - unreads: Unreads( - initial: initialSnapshot.unreadMsgs, - core: core, - channelStore: channels, - ), + messages: MessageStoreImpl(realm: realm), + unreads: Unreads(core: core, channelStore: channels, + initial: initialSnapshot.unreadMsgs), recentDmConversationsView: RecentDmConversationsView(core: core, initial: initialSnapshot.recentPrivateConversations), recentSenders: RecentSenders(), @@ -515,38 +511,32 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor PerAccountStore._({ required super.core, - required this.realmWildcardMentionPolicy, - required this.realmMandatoryTopics, - required this.realmWaitingPeriodThreshold, - required this.maxFileUploadSizeMib, - required String? realmEmptyTopicDisplayName, - required this.realmAllowMessageEditing, - required this.realmMessageContentEditLimitSeconds, - required this.realmDefaultExternalAccounts, - required this.customProfileFields, - required this.emailAddressVisibility, + required UserGroupStoreImpl groups, + required RealmStoreImpl realm, 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, required this.recentDmConversationsView, required this.recentSenders, - }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, + }) : _groups = groups, + _realm = realm, _emoji = emoji, _savedSnippets = savedSnippets, _users = users, _channels = channels, _messages = messages; - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// // Data. - //////////////////////////////// + //|////////////////////////////// // Where data comes from in the first place. UpdateMachine? get updateMachine => _updateMachine; @@ -557,77 +547,44 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor _updateMachine = value; } - bool get isLoading => _isLoading; - bool _isLoading = false; + bool get isRecoveringEventStream => _isRecoveringEventStream; + bool _isRecoveringEventStream = false; @visibleForTesting - set isLoading(bool value) { - if (_isLoading == value) return; - _isLoading = value; + set isRecoveringEventStream(bool value) { + if (_isRecoveringEventStream == value) return; + _isRecoveringEventStream = value; notifyListeners(); } - //////////////////////////////// + //|////////////////////////////// // Data attached to the realm or the server. - 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 int maxFileUploadSizeMib; // No event for this. - - /// 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 - - final Map realmDefaultExternalAccounts; - List customProfileFields; - /// For docs, please see [InitialSnapshot.emailAddressVisibility]. - final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting - - //////////////////////////////// - // The realm's repertoire of available emoji. - + // (User groups come before even realm settings, + // because they'll be used for interpreting many realm settings.) + @protected @override - EmojiDisplay emojiDisplayFor({ - required ReactionType emojiType, - required String emojiCode, - required String emojiName - }) { - return _emoji.emojiDisplayFor( - emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); - } + UserGroupStore get userGroupStore => _groups; + final UserGroupStoreImpl _groups; + @protected @override - Map>? get debugServerEmojiData => _emoji.debugServerEmojiData; + RealmStore get realmStore => _realm; + final RealmStoreImpl _realm; - @override void setServerEmojiData(ServerEmojiData data) { _emoji.setServerEmojiData(data); notifyListeners(); } + @protected @override - Iterable popularEmojiCandidates() => _emoji.popularEmojiCandidates(); + EmojiStore get emojiStore => _emoji; + final EmojiStoreImpl _emoji; - @override - Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); - - EmojiStoreImpl _emoji; - - //////////////////////////////// + //|////////////////////////////// // Data attached to the self-account on the realm. - final UserSettings? userSettings; // TODO(server-5) + final UserSettings userSettings; @override Map get savedSnippets => _savedSnippets.savedSnippets; @@ -635,156 +592,55 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final TypingNotifier typingNotifier; - //////////////////////////////// + //|////////////////////////////// // Users and data about them. + @protected @override - User? getUser(int userId) => _users.getUser(userId); - - @override - Iterable get allUsers => _users.allUsers; - + UserStore get userStore => _users; final UserStoreImpl _users; final TypingStatus typingStatus; - /// Whether [user] has passed the realm's waiting period to be a full member. - /// - /// See: - /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member - /// - /// To determine if a user is a full member, callers must also check that the - /// user's role is at least [UserRole.member]. - bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { - // [User.dateJoined] is in UTC. For logged-in users, the format is: - // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. - // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't - // include the timezone offset. In the later case, [DateTime.parse] will - // interpret it as the client's local timezone, which could lead to - // incorrect results; but that's acceptable for now because the app - // doesn't support viewing as a spectator. - // - // See the related discussion: - // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 - final dateJoined = DateTime.parse(user.dateJoined); - return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; - } + final Presence presence; - /// The given 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) { - 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. - // Search for "delivery_email" in https://zulip.com/api/register-queue. - return user.deliveryEmail; - } else { - if (user.deliveryEmail != null) { - // A non-null value means self-user has access to [user]'s real email, - // while a null value doesn't necessarily mean it doesn't have access - // to the email, .... - return user.deliveryEmail; - } else if (emailAddressVisibility == EmailAddressVisibility.everyone) { - // ... we have to also check for [PerAccountStore.emailAddressVisibility]. - // See: - // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 - // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 - return user.email; - } else { - return null; - } - } - } - - //////////////////////////////// + //|////////////////////////////// // Streams, topics, and stuff about them. + @protected @override - Map get streams => _channels.streams; - @override - Map get streamsByName => _channels.streamsByName; - @override - Map get subscriptions => _channels.subscriptions; - @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => - _channels.topicVisibilityPolicy(streamId, topic); - @override - Map> get debugTopicVisibility => - _channels.debugTopicVisibility; - + ChannelStore get channelStore => _channels; final ChannelStoreImpl _channels; - bool hasPostingPermission({ - required ZulipStream inChannel, - required User user, - required DateTime byDate, - }) { - final role = user.role; - // We let the users with [unknown] role to send the message, then the server - // will decide to accept it or not based on its actual role. - if (role == UserRole.unknown) return true; - - switch (inChannel.channelPostPolicy) { - case ChannelPostPolicy.any: return true; - case ChannelPostPolicy.fullMembers: { - if (!role.isAtLeast(UserRole.member)) return false; - return role == UserRole.member - ? hasPassedWaitingPeriod(user, byDate: byDate) - : true; - } - case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); - case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); - case ChannelPostPolicy.unknown: return true; - } - } - - //////////////////////////////// + //|////////////////////////////// // Messages, and summaries of messages. - @override - Map get messages => _messages.messages; - @override - void registerMessageList(MessageListView view) => - _messages.registerMessageList(view); - @override - void unregisterMessageList(MessageListView view) => - _messages.unregisterMessageList(view); - @override - Future sendMessage({required MessageDestination destination, required String content}) { - assert(!_disposed); - return _messages.sendMessage(destination: destination, content: content); - } - @override + /// Reconcile a batch of just-fetched messages with the store, + /// mutating the list. + /// + /// This is called after a [getMessages] request to report the result + /// to the store. + /// + /// The list's length will not change, but some entries may be replaced + /// by a different [Message] object with the same [Message.id], + /// and the store will also be updated. + /// When this method returns, all [Message] objects in the list + /// will be present in the map `this.messages`. + /// + /// The list entries may be mutated to remove + /// [Message.matchContent] and [Message.matchTopic] + /// (since these are appropriate for search views but not the central store). + /// The values of those fields should therefore be captured, + /// as needed for search, before this is called. 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); - } + @protected @override - Set get debugMessageListViews => _messages.debugMessageListViews; - + MessageStore get messageStore => _messages; final MessageStoreImpl _messages; final Unreads unreads; @@ -793,13 +649,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final RecentSenders recentSenders; - //////////////////////////////// + //|////////////////////////////// // Other digests of data. final AutocompleteViewManager autocompleteViewManager = AutocompleteViewManager(); // End of data. - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// /// Called when the app is reassembled during debugging, e.g. for hot reload. /// @@ -818,6 +674,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor recentDmConversationsView.dispose(); unreads.dispose(); _messages.dispose(); + presence.dispose(); typingStatus.dispose(); typingNotifier.dispose(); updateMachine?.dispose(); @@ -850,17 +707,24 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor } switch (event.property!) { case UserSettingName.twentyFourHourTime: - userSettings?.twentyFourHourTime = event.value as bool; + userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; case UserSettingName.displayEmojiReactionUsers: - userSettings?.displayEmojiReactionUsers = event.value as bool; + userSettings.displayEmojiReactionUsers = event.value as bool; case UserSettingName.emojiset: - userSettings?.emojiset = event.value as Emojiset; + userSettings.emojiset = event.value as Emojiset; + case UserSettingName.presenceEnabled: + userSettings.presenceEnabled = event.value as bool; } notifyListeners(); case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); - customProfileFields = _sortCustomProfileFields(event.fields); + _realm.handleCustomProfileFieldsEvent(event); + notifyListeners(); + + case UserGroupEvent(): + assert(debugLog("server event: user_group/${event.op}")); + _groups.handleUserGroupEvent(event); notifyListeners(); case RealmUserAddEvent(): @@ -895,6 +759,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor _channels.handleSubscriptionEvent(event); notifyListeners(); + case UserStatusEvent(): + assert(debugLog("server event: user_status")); + _users.handleUserStatusEvent(event); + notifyListeners(); + case UserTopicEvent(): assert(debugLog("server event: user_topic")); _messages.handleUserTopicEvent(event); @@ -904,6 +773,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor case MessageEvent(): assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}")); + // Assert against malformed events that might be created in test code. + assert(event.message.matchContent == null); + assert(event.message.matchTopic == null); + _messages.handleMessageEvent(event); unreads.handleMessageEvent(event); recentDmConversationsView.handleMessageEvent(event); @@ -939,30 +812,26 @@ 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")); + _messages.handleMutedUsersEvent(event); + // Update _users last, so other handlers can compare to the old value. + _users.handleMutedUsersEvent(event); + notifyListeners(); + case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } } - 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 - // the order they appear in the array (`custom_profile_fields` in the - // API; our `realmFields` array here.) See chat thread: - // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 - // - // We go on to put at the start of the list any fields that are marked for - // displaying in the "profile summary". (Possibly they should be at the - // start of the list in the first place, but make sure just in case.) - final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); - final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); - return displayFields.followedBy(nonDisplayFields).toList(); - } - @override String toString() => '${objectRuntimeType(this, 'PerAccountStore')}#${shortHash(this)}'; } @@ -1060,7 +929,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. @@ -1196,6 +1065,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; } @@ -1349,7 +1219,13 @@ class UpdateMachine { final GetEventsResult result; try { result = await getEvents(store.connection, - queueId: store.queueId, lastEventId: lastEventId); + queueId: store.queueId, + lastEventId: lastEventId, + // If the UI shows we're busy getting event-polling to work again, + // ask the server to tell us immediately that it's working again, + // rather than waiting for an event, which could take up to a minute + // in the case of a heartbeat event. See #979. + dontBlock: store.isRecoveringEventStream ? true : null); if (_disposed) return; } catch (e, stackTrace) { if (_disposed) return; @@ -1416,14 +1292,14 @@ class UpdateMachine { // if we stayed at the max backoff interval; partway toward what would // happen if we weren't backing off at all. // - // But at least for [getEvents] requests, as here, it should be OK, - // because this is a long-poll. That means a typical successful request - // takes a long time to come back; in fact longer than our max backoff - // duration (which is 10 seconds). So if we're getting a mix of successes - // and failures, the successes themselves should space out the requests. + // Successful retries won't actually space out the requests, because retries + // are done with the `dont_block` param, asking the server to respond + // immediately instead of waiting through the long-poll period. + // (See comments on that code for why this behavior is helpful.) + // If server logs show pressure from too many requests, we can investigate. _pollBackoffMachine = null; - store.isLoading = false; + store.isRecoveringEventStream = false; _accumulatedTransientFailureCount = 0; reportErrorToUserBriefly(null); } @@ -1442,7 +1318,7 @@ class UpdateMachine { /// * [_handlePollError], which handles errors from the rest of [poll] /// and errors this method rethrows. Future _handlePollRequestError(Object error, StackTrace stackTrace) async { - store.isLoading = true; + store.isRecoveringEventStream = true; if (error is! ApiRequestException) { // Some unexpected error, outside even making the HTTP request. @@ -1500,7 +1376,7 @@ class UpdateMachine { // or an unexpected exception representing a bug in our code or the server. // Either way, the show must go on. So reload server data from scratch. - store.isLoading = true; + store.isRecoveringEventStream = true; bool isUnexpected; // TODO(#1054): handle auth failure diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index 1ddd72c48b..fdb63339bc 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -6,23 +6,18 @@ import '../api/model/events.dart'; import '../api/route/typing.dart'; import 'binding.dart'; import 'narrow.dart'; -import 'store.dart'; +import 'realm.dart'; /// The model for tracking the typing status organized by narrows. /// /// Listeners are notified when a typist is added or removed from any narrow. -class TypingStatus extends PerAccountStoreBase with ChangeNotifier { - TypingStatus({ - required super.core, - required this.typingStartedExpiryPeriod, - }); - - final Duration typingStartedExpiryPeriod; +class TypingStatus extends HasRealmStore with ChangeNotifier { + TypingStatus({required super.realm}); Iterable get debugActiveNarrows => _timerMapsByNarrow.keys; Iterable typistIdsInNarrow(SendableNarrow narrow) => - _timerMapsByNarrow[narrow]?.keys ?? []; + _timerMapsByNarrow[narrow]?.keys ?? const []; // Using SendableNarrow as the key covers the narrows // where typing notices are supported (topics and DMs). @@ -47,7 +42,7 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { final typistTimer = narrowTimerMap[typistUserId]; final isNewTypist = typistTimer == null; typistTimer?.cancel(); - narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () { + narrowTimerMap[typistUserId] = Timer(serverTypingStartedExpiryPeriod, () { if (_removeTypist(narrow, typistUserId)) { notifyListeners(); } @@ -92,15 +87,8 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { /// See also: /// * https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts /// * https://zulip.readthedocs.io/en/latest/subsystems/typing-indicators.html -class TypingNotifier extends PerAccountStoreBase { - TypingNotifier({ - required super.core, - required this.typingStoppedWaitPeriod, - required this.typingStartedWaitPeriod, - }); - - final Duration typingStoppedWaitPeriod; - final Duration typingStartedWaitPeriod; +class TypingNotifier extends HasRealmStore { + TypingNotifier({required super.realm}); SendableNarrow? _currentDestination; @@ -137,7 +125,7 @@ class TypingNotifier extends PerAccountStoreBase { if (destination == _currentDestination) { // Nothing has really changed, except we may need // to send a ping to the server and extend out our idle time. - if (_sinceLastPing!.elapsed > typingStartedWaitPeriod) { + if (_sinceLastPing!.elapsed > serverTypingStartedWaitPeriod) { _actuallyPingServer(); } _startOrExtendIdleTimer(); @@ -179,7 +167,7 @@ class TypingNotifier extends PerAccountStoreBase { void _startOrExtendIdleTimer() { _idleTimer?.cancel(); - _idleTimer = Timer(typingStoppedWaitPeriod, _stopLastNotification); + _idleTimer = Timer(serverTypingStoppedWaitPeriod, _stopLastNotification); } void _actuallyPingServer() { diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 254b615452..cda2fa5753 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -37,18 +37,26 @@ import 'store.dart'; // messages and refresh [mentions] (see [mentions] dartdoc). class Unreads extends PerAccountStoreBase with ChangeNotifier { factory Unreads({ - required UnreadMessagesSnapshot initial, required CorePerAccountStore core, required ChannelStore channelStore, + required UnreadMessagesSnapshot initial, }) { - final streams = >>{}; + final streams = >>{}; final dms = >{}; final mentions = Set.of(initial.mentions); for (final unreadChannelSnapshot in initial.channels) { final streamId = unreadChannelSnapshot.streamId; final topic = unreadChannelSnapshot.topic; - (streams[streamId] ??= {})[topic] = QueueList.from(unreadChannelSnapshot.unreadMessageIds); + final topics = (streams[streamId] ??= makeTopicKeyedMap()); + topics.update(topic, + // Older servers differentiate topics case-sensitively, but shouldn't: + // https://github.com/zulip/zulip/pull/31869 + // Our topic-keyed map is case-insensitive. When we've seen this + // topic before, modulo case, aggregate instead of clobbering. + // TODO(server-10) simplify away + (value) => setUnion(value, unreadChannelSnapshot.unreadMessageIds), + ifAbsent: () => QueueList.from(unreadChannelSnapshot.unreadMessageIds)); } for (final unreadDmSnapshot in initial.dms) { @@ -88,7 +96,10 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { // int count; /// Unread stream messages, as: stream ID → topic → message IDs (sorted). - final Map>> streams; + /// + /// The topic-keyed map is case-insensitive and case-preserving; + /// it comes from [makeTopicKeyedMap]. + final Map>> streams; /// Unread DM messages, as: DM narrow → message IDs (sorted). final Map> dms; @@ -197,6 +208,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 +225,8 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { return countInMentionsNarrow(); case StarredMessagesNarrow(): return countInStarredMessagesNarrow(); + case KeywordSearchNarrow(): + return countInKeywordSearchNarrow(); } } @@ -400,7 +416,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { _slowRemoveAllInDms(messageIdsSet); } case UpdateMessageFlagsRemoveEvent(): - final newlyUnreadInStreams = >>{}; + final newlyUnreadInStreams = >>{}; final newlyUnreadInDms = >{}; for (final messageId in event.messages) { final detail = event.messageDetails![messageId]; @@ -415,7 +431,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { } switch (detail.type) { case MessageType.stream: - final topics = (newlyUnreadInStreams[detail.streamId!] ??= {}); + final topics = (newlyUnreadInStreams[detail.streamId!] ??= makeTopicKeyedMap()); final messageIds = (topics[detail.topic!] ??= QueueList()); messageIds.add(messageId); case MessageType.direct: @@ -441,22 +457,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; @@ -485,14 +499,15 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { } void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) { - ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId); + ((streams[streamId] ??= makeTopicKeyedMap())[topic] ??= QueueList()) + .addLast(messageId); } // [messageIds] must be sorted ascending and without duplicates. void _addAllInStreamTopic(QueueList messageIds, int streamId, TopicName topic) { assert(messageIds.isNotEmpty); assert(isSortedWithoutDuplicates(messageIds)); - final topics = streams[streamId] ??= {}; + final topics = streams[streamId] ??= makeTopicKeyedMap(); topics.update(topic, ifAbsent: () => messageIds, // setUnion dedupes existing and incoming unread IDs, diff --git a/lib/model/user.dart b/lib/model/user.dart index 05ab2747df..7d195e3197 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,11 +1,19 @@ +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'algorithms.dart'; import 'localizations.dart'; +import 'narrow.dart'; +import 'realm.dart'; import 'store.dart'; /// The portion of [PerAccountStore] describing the users in the realm. -mixin UserStore on PerAccountStoreBase { +mixin UserStore on PerAccountStoreBase, RealmStore { + @protected + RealmStore get realmStore; + /// The user with the given ID, if that user is known. /// /// There may be other users that are perfectly real but are @@ -27,7 +35,10 @@ mixin UserStore on PerAccountStoreBase { /// Consider using [userDisplayName]. User? getUser(int userId); - /// All known users in the realm. + /// All known users in the realm, including deactivated users. + /// + /// Before presenting these users in the UI, consider whether to exclude + /// users who are deactivated (see [User.isActive]) or muted ([isUserMuted]). /// /// This may have a large number of elements, like tens of thousands. /// Consider [getUser] or other alternatives to iterating through this. @@ -44,28 +55,169 @@ 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; + } + + /// The user's real email address, if known, for displaying in the UI. + /// + /// 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. + // Search for "delivery_email" in https://zulip.com/api/register-queue. + return user.deliveryEmail; + } else { + if (user.deliveryEmail != null) { + // A non-null value means self-user has access to [user]'s real email, + // while a null value doesn't necessarily mean it doesn't have access + // to the email, .... + return user.deliveryEmail; + } else if (emailAddressVisibility == EmailAddressVisibility.everyone) { + // ... we have to also check for [PerAccountStore.emailAddressVisibility]. + // See: + // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 + // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 + return user.email; + } else { + return null; + } + } + } + + /// Whether [user] has passed the realm's waiting period to be a full member. + /// + /// See: + /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member + /// + /// To determine if a user is a full member, callers must also check that the + /// user's role is at least [UserRole.member]. + bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { + // [User.dateJoined] is in UTC. For logged-in users, the format is: + // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. + // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't + // include the timezone offset. In the later case, [DateTime.parse] will + // interpret it as the client's local timezone, which could lead to + // incorrect results; but that's acceptable for now because the app + // doesn't support viewing as a spectator. + // + // See the related discussion: + // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 + final dateJoined = DateTime.parse(user.dateJoined); + return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } + + /// 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}); + + /// Whether the self-user has muted everyone in [narrow]. + /// + /// Returns false for the self-DM. + /// + /// Calls [isUserMuted] for each participant, passing along [event]. + bool shouldMuteDmConversation(DmNarrow narrow, {MutedUsersEvent? event}) { + if (narrow.otherRecipientIds.isEmpty) return false; + return narrow.otherRecipientIds.every( + (userId) => isUserMuted(userId, event: event)); + } + + /// Whether the given event might change the result of [shouldMuteDmConversation] + /// for its list of muted users, compared to the current state. + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event); + + /// The status of the user with the given ID. + /// + /// If no status is set for the user, returns [UserStatus.zero]. + UserStatus getUserStatus(int userId); +} + +/// Whether and how a given [MutedUsersEvent] may affect the results +/// that [UserStore.shouldMuteDmConversation] would give for some messages. +enum MutedUsersVisibilityEffect { + /// The event will have no effect on the visibility results. + none, + + /// The event may change some visibility results from true to false. + muted, + + /// The event may change some visibility results from false to true. + unmuted, + + /// The event may change some visibility results from false to true, + /// and some from true to false. + mixed; +} + +mixin ProxyUserStore on UserStore { + @protected + UserStore get userStore; + + @override + User? getUser(int userId) => userStore.getUser(userId); + + @override + Iterable get allUsers => userStore.allUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) => + userStore.isUserMuted(userId, event: event); + + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => + userStore.mightChangeShouldMuteDmConversation(event); + + @override + UserStatus getUserStatus(int userId) => userStore.getUserStatus(userId); +} + +/// A base class for [PerAccountStore] substores that need access to [UserStore] +/// as well as to its prerequisites [CorePerAccountStore] and [RealmStore]. +abstract class HasUserStore extends HasRealmStore with UserStore, ProxyUserStore { + HasUserStore({required UserStore users}) + : userStore = users, super(realm: users.realmStore); + + @protected + @override + final UserStore userStore; } /// The implementation of [UserStore] that does the work. @@ -73,15 +225,18 @@ mixin UserStore on PerAccountStoreBase { /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [UserStore] which describes its interface. -class UserStoreImpl extends PerAccountStoreBase with UserStore { +class UserStoreImpl extends HasRealmStore with UserStore { UserStoreImpl({ - required super.core, + required super.realm, required InitialSnapshot initialSnapshot, }) : _users = Map.fromEntries( 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)), + _userStatuses = initialSnapshot.userStatuses.map((userId, change) => + MapEntry(userId, change.apply(UserStatus.zero))); final Map _users; @@ -91,6 +246,41 @@ 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); + } + + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) { + final sortedOld = _mutedUsers.toList()..sort(); + final sortedNew = event.mutedUsers.map((u) => u.id).toList()..sort(); + assert(isSortedWithoutDuplicates(sortedOld)); + assert(isSortedWithoutDuplicates(sortedNew)); + final union = setUnion(sortedOld, sortedNew); + + final willMuteSome = sortedOld.length < union.length; + final willUnmuteSome = sortedNew.length < union.length; + + switch ((willUnmuteSome, willMuteSome)) { + case (true, false): + return MutedUsersVisibilityEffect.unmuted; + case (false, true): + return MutedUsersVisibilityEffect.muted; + case (true, true): + return MutedUsersVisibilityEffect.mixed; + case (false, false): // TODO(log)? + return MutedUsersVisibilityEffect.none; + } + } + + final Map _userStatuses; + + @override + UserStatus getUserStatus(int userId) => _userStatuses[userId] ?? UserStatus.zero; + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -129,4 +319,14 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } } + + void handleUserStatusEvent(UserStatusEvent event) { + _userStatuses[event.userId] = + event.change.apply(getUserStatus(event.userId)); + } + + void handleMutedUsersEvent(MutedUsersEvent event) { + _mutedUsers.clear(); + _mutedUsers.addAll(event.mutedUsers.map((item) => item.id)); + } } diff --git a/lib/model/user_group.dart b/lib/model/user_group.dart new file mode 100644 index 0000000000..2079b323ec --- /dev/null +++ b/lib/model/user_group.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +/// The portion of [PerAccountStore] describing user groups. +mixin UserGroupStore on PerAccountStoreBase { + /// The user group with the given ID, if any. + UserGroup? getGroup(int userGroupId); + + /// All non-deactivated user groups in the realm. + /// + /// For when deactivated groups are desired too, see [allGroups]. + Iterable get activeGroups; + + /// All user groups in the realm, even those deactivated. + /// + /// Consider using [activeGroups] instead. + Iterable get allGroups; +} + +mixin ProxyUserGroupStore on UserGroupStore { + @protected + UserGroupStore get userGroupStore; + + @override + UserGroup? getGroup(int userGroupId) => userGroupStore.getGroup(userGroupId); + @override + Iterable get activeGroups => userGroupStore.activeGroups; + @override + Iterable get allGroups => userGroupStore.allGroups; +} + +/// The implementation of [UserGroupStore] that does the work. +class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore { + UserGroupStoreImpl({required super.core, required List groups}) + : _groups = { + for (final group in groups) + group.id: group, + }; + + @override + UserGroup? getGroup(int userGroupId) { + return _groups[userGroupId]; + } + + @override + Iterable get activeGroups { + return _groups.values.where((group) => !group.deactivated); + } + + @override + Iterable get allGroups { + return _groups.values; + } + + final Map _groups; + + void handleUserGroupEvent(UserGroupEvent event) { + switch (event) { + case UserGroupAddEvent(): + _groups[event.group.id] = event.group; + + case UserGroupRemoveEvent(): + _groups.remove(event.groupId); + + case UserGroupUpdateEvent(): + final group = _groups[event.groupId]; + if (group == null) { + return; // TODO log + } + final data = event.data; + if (data.name != null) group.name = data.name!; + if (data.description != null) group.description = data.description!; + if (data.deactivated != null) group.deactivated = data.deactivated!; + } + } +} 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 04e535e65a..6b280df6ee 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -13,12 +13,15 @@ import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; +import '../model/content.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; +import 'content.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'emoji_reaction.dart'; @@ -29,13 +32,18 @@ import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( - BuildContext context, { + BuildContext pageContext, { + Widget? header, required List optionButtons, }) { + // Could omit this if we need _showActionSheet outside a per-account context. + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + showModalBottomSheet( - context: context, + context: pageContext, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": // https://api.flutter.dev/flutter/dart-ui/Clip.html @@ -43,27 +51,49 @@ void _showActionSheet( useSafeArea: true, isScrollControlled: true, builder: (BuildContext _) { - return Semantics( - role: SemanticsRole.menu, - child: SafeArea( - minimum: const EdgeInsets.only(bottom: 16), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + final designVariables = DesignVariables.of(pageContext); + return PerAccountStoreWidget( + accountId: accountId, + child: Semantics( + role: SemanticsRole.menu, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - // TODO(#217): show message text - Flexible(child: InsetShadowBox( - top: 8, bottom: 8, - color: DesignVariables.of(context).bgContextMenu, - child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(7), - child: Column(spacing: 1, - children: optionButtons))))), - const ActionSheetCancelButton(), + if (header != null) + Flexible( + // TODO(upstream) Enforce a flex ratio (e.g. 1:3) + // only when the header height plus the buttons' height + // exceeds available space. Otherwise let one or the other + // grow to fill available space even if it breaks the ratio. + // Needs support for separate properties like `flex-grow` + // and `flex-shrink`. + flex: 1, + child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: 8), + child: header))) + else + SizedBox(height: 8), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: MenuButtonsShape(buttons: optionButtons)))), + const ActionSheetCancelButton(), + ]))), ])))); }); } @@ -121,22 +151,12 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { @override Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - return MenuItemButton( - trailingIcon: Icon(icon, color: designVariables.contextMenuItemText), - style: MenuItemButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - foregroundColor: designVariables.contextMenuItemText, - splashFactory: NoSplash.splashFactory, - ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => - designVariables.contextMenuItemBg.withFadedAlpha( - states.contains(WidgetState.pressed) ? 0.20 : 0.12))), + return ZulipMenuItemButton( + icon: icon, + label: label(zulipLocalizations), onPressed: () => _handlePressed(context), - child: Text(label(zulipLocalizations), - style: const TextStyle(fontSize: 20, height: 24 / 20) - .merge(weightVariableTextStyle(context, wght: 600)), - )); + ); } } @@ -175,24 +195,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, @@ -569,6 +608,8 @@ 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 = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), @@ -577,6 +618,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes 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), @@ -584,7 +628,42 @@ void showMessageActionSheet({required BuildContext context, required Message mes EditButton(message: message, pageContext: pageContext), ]; - _showActionSheet(pageContext, optionButtons: optionButtons); + _showActionSheet(pageContext, + optionButtons: optionButtons, + header: _MessageActionSheetHeader(message: message)); +} + +class _MessageActionSheetHeader extends StatelessWidget { + const _MessageActionSheetHeader({required this.message}); + + final Message message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + // TODO this seems to lose the hero animation when opening an image; + // investigate. + // TODO should we close the sheet before opening a narrow link? + // On popping the pushed narrow route, the sheet is still open. + + return Container( + // TODO(#647) use different color for highlighted messages + // TODO(#681) use different color for DM messages + color: designVariables.bgMessageRegular, + padding: EdgeInsets.symmetric(vertical: 4), + child: Column( + spacing: 4, + children: [ + SenderRow(message: message, + timestampStyle: MessageTimestampStyle.full), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + // TODO(#10) offer text selection; the Figma asks for it here: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-30210&m=dev + child: MessageContent(message: message, content: parseMessageContent(message))), + ])); + } } abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton { @@ -695,7 +774,6 @@ class ReactionButtons extends StatelessWidget { : null, child: UnicodeEmojiWidget( emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay, - notoColorEmojiTextSize: 20.1, size: 24)))); } @@ -813,7 +891,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionQuoteAndReply; + return zulipLocalizations.actionSheetOptionQuoteMessage; } @override void onPressed() async { @@ -876,9 +954,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.maybeRevealedMutedMessagesOf(pageContext)! + .isMutedMessageRevealed(message.id)); + findMessageListPage().unrevealMutedMessage(message.id); } } diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 61032d81e1..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({ 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/app_bar.dart b/lib/widgets/app_bar.dart index f548557681..77f77ba7e2 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -79,7 +79,7 @@ class _ZulipAppBarBottom extends StatelessWidget implements PreferredSizeWidget @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - if (!store.isLoading) return const SizedBox.shrink(); + if (!store.isRecoveringEventStream) return const SizedBox.shrink(); return LinearProgressIndicator(minHeight: 4.0, backgroundColor: backgroundColor); } } diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a1956295eb..cb5d9d078c 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/emoji.dart'; import '../model/store.dart'; -import 'content.dart'; import 'emoji.dart'; import 'icons.dart'; import 'store.dart'; @@ -13,6 +12,7 @@ import '../model/narrow.dart'; import 'compose_box.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; abstract class AutocompleteField extends StatefulWidget { const AutocompleteField({ @@ -130,7 +130,7 @@ class _AutocompleteFieldState) - fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + fieldViewBuilder: (context, _, _, _) => widget.fieldViewBuilder(context), ); } } @@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -238,8 +238,13 @@ class ComposeAutocomplete extends AutocompleteField ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _size, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index fb5968b97a..08af96806b 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'color.dart'; +import 'icons.dart'; import 'text.dart'; import 'theme.dart'; @@ -18,17 +19,30 @@ class ZulipWebUiKitButton extends StatelessWidget { super.key, this.attention = ZulipWebUiKitButtonAttention.medium, this.intent = ZulipWebUiKitButtonIntent.info, + this.size = ZulipWebUiKitButtonSize.normal, required this.label, + this.icon, required this.onPressed, }); final ZulipWebUiKitButtonAttention attention; final ZulipWebUiKitButtonIntent intent; + final ZulipWebUiKitButtonSize size; final String label; + final IconData? icon; final VoidCallback onPressed; WidgetStateColor _backgroundColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.neutralButtonBg.withFadedAlpha(0.3), + ~WidgetState.pressed: designVariables.neutralButtonBg.withAlpha(0), + }); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return WidgetStateColor.fromMap({ WidgetState.pressed: designVariables.btnBgAttMediumIntInfoActive, @@ -44,6 +58,13 @@ class ZulipWebUiKitButton extends StatelessWidget { Color _labelColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + // TODO nit: don't fade in pressed state + return designVariables.neutralButtonLabel.withFadedAlpha(0.85); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return designVariables.btnLabelAttMediumIntInfo; case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.info): @@ -53,7 +74,8 @@ class ZulipWebUiKitButton extends StatelessWidget { TextStyle _labelStyle(BuildContext context, {required TextScaler textScaler}) { final designVariables = DesignVariables.of(context); - // Values chosen from the Figma frame for zulip-flutter's compose box: + // Normal-size values chosen from the Figma frame for zulip-flutter's + // compose box: // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev // Commented values come from the Figma page "Zulip Web UI kit": // https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-8&p=f&m=dev @@ -61,17 +83,22 @@ class ZulipWebUiKitButton extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023880851 return TextStyle( color: _labelColor(designVariables), - fontSize: 17, // 16 - height: 1.20, // 1.25 - letterSpacing: proportionalLetterSpacing(context, textScaler: textScaler, - 0.006, - baseFontSize: 17), // 16 + fontSize: _forSize(16, 17 /* 16 */), + height: _forSize(1, 1.20 /* 1.25 */), + letterSpacing: _forSize( + 0, + proportionalLetterSpacing(context, textScaler: textScaler, + 0.006, + baseFontSize: 17 /* 16 */), + ), ).merge(weightVariableTextStyle(context, wght: 600)); // 500 } BorderSide _borderSide(DesignVariables designVariables) { switch (attention) { + case ZulipWebUiKitButtonAttention.minimal: + return BorderSide.none; case ZulipWebUiKitButtonAttention.medium: // TODO inner shadow effect like `box-shadow: inset`, following Figma; // needs Flutter support for something like that: @@ -87,6 +114,12 @@ class ZulipWebUiKitButton extends StatelessWidget { } } + T _forSize(T small, T normal) => + switch (size) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -104,27 +137,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 +186,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 +202,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 { @@ -195,3 +258,229 @@ class _AnimatedScaleOnTapState extends State { child: widget.child)); } } + +/// The rounded-rectangle shape and 1-pixel spacing for a run of [ZulipMenuItemButton]s. +class MenuButtonsShape extends StatelessWidget { + const MenuButtonsShape({ + super.key, + required this.buttons, + }); + + final List buttons; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Column(spacing: 1, + children: buttons)); + } +} + +/// The "menu button" or "list button" component in Figma. +/// +/// Use [ZulipMenuItemButtonStyle] to choose between components. +/// +/// Must have a [MenuButtonsShape] ancestor. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60681&m=dev +class ZulipMenuItemButton extends StatelessWidget { + const ZulipMenuItemButton({ + super.key, + this.style = ZulipMenuItemButtonStyle.menu, + required this.label, + required this.onPressed, + this.icon, + this.toggle, + }); + + final ZulipMenuItemButtonStyle style; + final String label; + final VoidCallback onPressed; + final IconData? icon; + + /// A [Toggle] to go before [icon], or in its place if it's null. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60682&m=dev + // TODO(design) Is the toggle option meant only for + // [ZulipMenuItemButtonStyle.menu]? + final Widget? toggle; + + double get itemSpacingAndEndPadding => switch (style) { + ZulipMenuItemButtonStyle.menu => 16, + ZulipMenuItemButtonStyle.list => 12, + }; + + static bool _debugCheckShapeAncestor(BuildContext context) { + final ancestor = context.findAncestorWidgetOfExactType(); + assert(() { + if (ancestor != null) return true; + throw FlutterError.fromParts([ + ErrorSummary('No MenuButtonsShape ancestor found.'), + ErrorDescription('ZulipMenuItemButton widgets require a MenuButtonsShape ancestor.'), + ]); + }()); + return true; + } + + WidgetStateColor _backgroundColor(DesignVariables designVariables) { + switch (style) { + case ZulipMenuItemButtonStyle.menu: + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), + ~WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.12), + }); + case ZulipMenuItemButtonStyle.list: + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.7), + ~WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.35), + }); + } + } + + Color _labelColor(DesignVariables designVariables) { + return switch (style) { + ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemText, + ZulipMenuItemButtonStyle.list => designVariables.listMenuItemText, + }; + } + + double _labelWght() { + return switch (style) { + ZulipMenuItemButtonStyle.menu => 600, + ZulipMenuItemButtonStyle.list => 500, + }; + } + + Color _iconColor(DesignVariables designVariables) { + return switch (style) { + ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemIcon, + ZulipMenuItemButtonStyle.list => designVariables.listMenuItemIcon, + }; + } + + @override + Widget build(BuildContext context) { + _debugCheckShapeAncestor(context); + + final designVariables = DesignVariables.of(context); + + // (see `trailingIcon`) + assert(Theme.of(context).visualDensity == VisualDensity.standard); + + return MenuItemButton( + trailingIcon: (icon != null || toggle != null) + ? Padding( + // This Material widget gives us 12px padding before the icon -- + // or more or less, depending on Theme.of(context).visualDensity, + // hence the `assert` above. + padding: EdgeInsetsDirectional.only(start: itemSpacingAndEndPadding - 12), + + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: itemSpacingAndEndPadding, + children: [ + if (toggle != null) toggle!, + if (icon != null) Icon(icon!, color: _iconColor(designVariables)), + ])) + : null, + style: MenuItemButton.styleFrom( + minimumSize: Size.fromHeight(48), + padding: EdgeInsetsDirectional.only(start: 16, end: itemSpacingAndEndPadding), + foregroundColor: _labelColor(designVariables), + splashFactory: NoSplash.splashFactory, + ).copyWith(backgroundColor: _backgroundColor(designVariables)), + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + // TODO sublabel, for [ZulipMenuItemButtonStyle.list] + child: Text(label, + style: const TextStyle(fontSize: 20, height: 24 / 20) + .merge(weightVariableTextStyle(context, wght: _labelWght()))))); + } +} + +/// The style of a [ZulipMenuItemButton]. +enum ZulipMenuItemButtonStyle { + /// The purple "menu button" component in Figma, with 16px end padding. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3302-20443&m=dev + menu, + + /// The gray "list button" component in Figma, with 12px end padding. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5000-52868&m=dev + list, +} + +/// The "toggle" component in Figma. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60682&m=dev +class Toggle extends StatelessWidget { + const Toggle({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + // Figma has this (blue/500) in both light and dark mode. + // TODO(#831) + final activeColor = Color(0xff4370f0); + + // Figma has this (grey/400) in both light and dark mode. + // TODO(#831) + final inactiveColor = Color(0xff9194a3); + + // TODO(#1636): + // All of these just need _SwitchConfig to be exposed, + // and there's an upstream issue for that: + // https://github.com/flutter/flutter/issues/131478 + // + // - active thumb radius should be 10px, not 12px + // (_SwitchConfig.thumbRadiusWithIcon) + // - inactive thumb radius should be 7px, not 8px + // (_SwitchConfig.inactiveThumbRadius) + // - track dimensions before trackOutlineWidth should be 24px by 44px, + // not 32px by 52px (_SwitchConfig.trackHeight and trackWidth). + + return Switch( + value: value, + onChanged: onChanged, + padding: EdgeInsets.zero, + splashRadius: 0, + thumbIcon: WidgetStateProperty.fromMap({ + WidgetState.selected: Icon(ZulipIcons.check, size: 16, color: activeColor), + ~WidgetState.selected: null, + }), + + // Figma has white for "on" and "off" in both light and dark mode. + thumbColor: WidgetStatePropertyAll(Colors.white), + + activeTrackColor: activeColor, + inactiveTrackColor: inactiveColor, + trackOutlineColor: WidgetStateColor.fromMap({ + WidgetState.selected: activeColor, + ~WidgetState.selected: inactiveColor, + }), + trackOutlineWidth: WidgetStateProperty.fromMap({ + // The outline is effectively painted with strokeAlignCenter: + // https://api.flutter.dev/flutter/painting/BorderSide/strokeAlignCenter-constant.html + WidgetState.selected: 2 * 2, + ~WidgetState.selected: 1 * 2, + }), + overlayColor: WidgetStatePropertyAll(Colors.transparent), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d629e69029..6514631955 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -13,6 +13,7 @@ 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'; @@ -858,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; @@ -1288,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; @@ -1388,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( @@ -1546,6 +1541,15 @@ 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(); @@ -1609,6 +1613,19 @@ class StreamComposeBoxController extends ComposeBoxController { 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(); @@ -1724,10 +1741,10 @@ 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; } } @@ -1814,6 +1831,7 @@ class ComposeBox extends StatefulWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return false; } } @@ -1826,6 +1844,16 @@ class ComposeBox extends StatefulWidget { 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 @@ -1848,13 +1876,38 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM ComposeBoxController? _controller; @override - void startEditInteraction(int messageId) async { - if (await _abortBecauseContentInputNotEmpty()) return; - if (!mounted) return; + 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); @@ -1878,12 +1931,14 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM /// If there's text in the compose box, give a confirmation dialog /// asking if it can be discarded and await the result. - Future _abortBecauseContentInputNotEmpty() async { + Future _abortBecauseContentInputNotEmpty({ + required String dialogMessage, + }) async { final zulipLocalizations = ZulipLocalizations.of(context); if (controller.content.textNormalized.isNotEmpty) { final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.discardDraftConfirmationDialogTitle, - message: zulipLocalizations.discardDraftConfirmationDialogMessage, + message: dialogMessage, // TODO(#1032) "destructive" style for action button actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); if (await dialog.result != true) return true; @@ -1923,7 +1978,8 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // TODO timeout this request? if (!mounted) return; if (!identical(controller, emptyEditController)) { - // user tapped Cancel during the fetch-raw-content request + // 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; @@ -1994,6 +2050,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): assert(false); } } @@ -2028,6 +2085,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return null; } return null; @@ -2061,11 +2119,6 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } return ComposeBoxInheritedWidget.fromComposeBoxState(this, child: _ComposeBoxContainer(body: body, banner: banner)); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 40b510305d..5851222a1c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -11,21 +10,21 @@ import 'package:intl/intl.dart' as intl; import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; -import '../model/avatar_url.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; -import '../model/katex.dart'; import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; 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). /// @@ -817,130 +816,14 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); - } -} - -// Base text style from .katex class in katex.scss : -// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -const kBaseKatexTextStyle = TextStyle( - fontSize: kBaseFontSize * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2); - -class _Katex extends StatelessWidget { - const _Katex({ - required this.inline, - required this.nodes, - }); - - final bool inline; - final List nodes; - - @override - Widget build(BuildContext context) { - Widget widget = _KatexNodeList(nodes: nodes); - - if (!inline) { - widget = Center( + return Center( + child: Directionality( + textDirection: TextDirection.ltr, child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: widget)); - } - - return Directionality( - textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget)); - } -} - -class _KatexNodeList extends StatelessWidget { - const _KatexNodeList({required this.nodes}); - - final List nodes; - - @override - Widget build(BuildContext context) { - return Text.rich(TextSpan( - children: List.unmodifiable(nodes.map((e) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); - })))); - } -} - -class _KatexSpan extends StatelessWidget { - const _KatexSpan(this.node); - - final KatexNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - Widget widget = const SizedBox.shrink(); - if (node.text != null) { - widget = Text(node.text!); - } else if (node.nodes != null && node.nodes!.isNotEmpty) { - widget = _KatexNodeList(nodes: node.nodes!); - } - - final styles = node.styles; - - final fontFamily = styles.fontFamily; - final fontSize = switch (styles.fontSizeEm) { - double fontSizeEm => fontSizeEm * em, - null => null, - }; - final fontWeight = switch (styles.fontWeight) { - KatexSpanFontWeight.bold => FontWeight.bold, - null => null, - }; - var fontStyle = switch (styles.fontStyle) { - KatexSpanFontStyle.normal => FontStyle.normal, - KatexSpanFontStyle.italic => FontStyle.italic, - null => null, - }; - - TextStyle? textStyle; - if (fontFamily != null || - fontSize != null || - fontWeight != null || - fontStyle != null) { - // TODO(upstream) remove this workaround when upstream fixes the broken - // rendering of KaTeX_Math font with italic font style on Android: - // https://github.com/flutter/flutter/issues/167474 - if (defaultTargetPlatform == TargetPlatform.android && - fontFamily == 'KaTeX_Math') { - fontStyle = FontStyle.normal; - } - - textStyle = TextStyle( - fontFamily: fontFamily, - fontSize: fontSize, - fontWeight: fontWeight, - fontStyle: fontStyle, - ); - } - final textAlign = switch (styles.textAlign) { - KatexSpanTextAlign.left => TextAlign.left, - KatexSpanTextAlign.center => TextAlign.center, - KatexSpanTextAlign.right => TextAlign.right, - null => null, - }; - - if (textStyle != null || textAlign != null) { - widget = DefaultTextStyle.merge( - style: textStyle, - textAlign: textAlign, - child: widget); - } - return widget; + child: KatexWidget( + textStyle: ContentTheme.of(context).textStylePlainParagraph, + nodes: nodes)))); } } @@ -988,7 +871,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), @@ -1262,7 +1145,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(inline: true, nodes: nodes)); + child: KatexWidget(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, @@ -1421,13 +1304,26 @@ class GlobalTime extends StatelessWidget { final GlobalTimeNode node; final TextStyle ambientTextStyle; - static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date + static final _format12 = + intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', '); + static final _format24 = + intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', '); + static final _formatLocaleDefault = + intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', '); @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime; // Design taken from css for `.rendered_markdown & time` in web, // see zulip:web/styles/rendered_markdown.css . - final text = _dateFormat.format(node.datetime.toLocal()); + // TODO(i18n): localize; see plan with ffi in #45 + final format = switch (twentyFourHourTimeMode) { + TwentyFourHourTimeMode.twelveHour => _format12, + TwentyFourHourTimeMode.twentyFourHour => _format24, + TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault, + }; + final text = format.format(node.datetime.toLocal()); final contentTheme = ContentTheme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -1535,15 +1431,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. @@ -1651,96 +1550,6 @@ class RealmContentNetworkImage extends StatelessWidget { } } -/// A rounded square with size [size] showing a user's avatar. -class Avatar extends StatelessWidget { - const Avatar({ - super.key, - required this.userId, - required this.size, - required this.borderRadius, - }); - - final int userId; - final double size; - final double borderRadius; - - @override - Widget build(BuildContext context) { - return AvatarShape( - size: size, - borderRadius: borderRadius, - child: AvatarImage(userId: userId, size: size)); - } -} - -/// The appropriate avatar image for a user ID. -/// -/// If the user isn't found, gives a [SizedBox.shrink]. -/// -/// Wrap this with [AvatarShape]. -class AvatarImage extends StatelessWidget { - const AvatarImage({ - super.key, - required this.userId, - required this.size, - }); - - final int userId; - final double size; - - @override - Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final user = store.getUser(userId); - - if (user == null) { // TODO(log) - return const SizedBox.shrink(); - } - - final resolvedUrl = switch (user.avatarUrl) { - null => null, // TODO(#255): handle computing gravatars - var avatarUrl => store.tryResolveUrl(avatarUrl), - }; - - if (resolvedUrl == null) { - return const SizedBox.shrink(); - } - - final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); - final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); - - return RealmContentNetworkImage( - avatarUrl.get(physicalSize), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - ); - } -} - -/// A rounded square shape, to wrap an [AvatarImage] or similar. -class AvatarShape extends StatelessWidget { - const AvatarShape({ - super.key, - required this.size, - required this.borderRadius, - required this.child, - }); - - final double size; - final double borderRadius; - final Widget child; - - @override - Widget build(BuildContext context) { - return SizedBox.square( - dimension: size, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(borderRadius)), - clipBehavior: Clip.antiAlias, - child: child)); - } -} - // // Small helpers. // diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 08ce8f08c7..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( @@ -52,10 +56,12 @@ class DialogStatus { /// /// 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, @@ -86,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, @@ -108,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.dart b/lib/widgets/emoji.dart index dafeb7b6d8..ba26af3450 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -9,7 +9,6 @@ class UnicodeEmojiWidget extends StatelessWidget { super.key, required this.emojiDisplay, required this.size, - required this.notoColorEmojiTextSize, this.textScaler = TextScaler.noScaling, }); @@ -20,12 +19,6 @@ class UnicodeEmojiWidget extends StatelessWidget { /// This will be scaled by [textScaler]. final double size; - /// A font size that, with Noto Color Emoji and our line-height config, - /// causes a Unicode emoji to occupy a square of size [size] in the layout. - /// - /// This has to be determined experimentally, as far as we know. - final double notoColorEmojiTextSize; - /// The text scaler to apply to [size]. /// /// Defaults to [TextScaler.noScaling]. @@ -38,6 +31,15 @@ class UnicodeEmojiWidget extends StatelessWidget { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + // A font size that, with Noto Color Emoji and our line-height + // config (the use of `forceStrutHeight: true`), causes a Unicode emoji + // to occupy a square of size [size] in the layout. + // + // Determined experimentally: + // + // + final double notoColorEmojiTextSize = size * (14.5 / 17); + return Text( textScaler: textScaler, style: TextStyle( @@ -45,7 +47,10 @@ class UnicodeEmojiWidget extends StatelessWidget { fontSize: notoColorEmojiTextSize, ), strutStyle: StrutStyle( - fontSize: notoColorEmojiTextSize, forceStrutHeight: true), + fontSize: notoColorEmojiTextSize, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode); case TargetPlatform.iOS: @@ -74,7 +79,11 @@ class UnicodeEmojiWidget extends StatelessWidget { style: TextStyle( fontFamily: 'Apple Color Emoji', fontSize: size), - strutStyle: StrutStyle(fontSize: size, forceStrutHeight: true), + strutStyle: StrutStyle( + fontSize: size, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode)), ]); } @@ -89,6 +98,7 @@ class ImageEmojiWidget extends StatelessWidget { required this.size, this.textScaler = TextScaler.noScaling, this.errorBuilder, + this.neverAnimate = false, }); final ImageEmojiDisplay emojiDisplay; @@ -105,13 +115,20 @@ class ImageEmojiWidget extends StatelessWidget { final ImageErrorWidgetBuilder? errorBuilder; + /// Whether to show an animated emoji in its still (non-animated) variant + /// only, even if device settings permit animation. + /// + /// Defaults to false. + final bool neverAnimate; + @override Widget build(BuildContext context) { // Some people really dislike animated emoji. final doNotAnimate = + neverAnimate // From reading code, this doesn't actually get set on iOS: // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 - MediaQuery.disableAnimationsOf(context) + || MediaQuery.disableAnimationsOf(context) || (defaultTargetPlatform == TargetPlatform.iOS // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely // relevant setting than "reduce motion". It's called "auto-play diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 0f6d490a97..3c26361d3a 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -121,7 +121,7 @@ class ReactionChipsList extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false; + final displayEmojiReactionUsers = store.userSettings.displayEmojiReactionUsers ?? false; final showNames = displayEmojiReactionUsers && reactions.total <= 3; return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, @@ -270,13 +270,6 @@ class ReactionChip extends StatelessWidget { /// Should be scaled by [_emojiTextScalerClamped]. const _squareEmojiSize = 17.0; -/// A font size that, with Noto Color Emoji and our line-height config, -/// causes a Unicode emoji to occupy a [_squareEmojiSize] square in the layout. -/// -/// Determined experimentally: -/// -const _notoColorEmojiTextSize = 14.5; - /// A [TextScaler] that limits Unicode and image emojis' max scale factor, /// to leave space for the label. /// @@ -306,7 +299,6 @@ class _UnicodeEmoji extends StatelessWidget { Widget build(BuildContext context) { return UnicodeEmojiWidget( size: _squareEmojiSize, - notoColorEmojiTextSize: _notoColorEmojiTextSize, textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay); } @@ -330,7 +322,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), ); } @@ -563,7 +555,6 @@ class EmojiPickerListEntry extends StatelessWidget { final Message message; static const _emojiSize = 24.0; - static const _notoColorEmojiTextSize = 20.1; void _onPressed() { // Dismiss the enclosing action sheet immediately, @@ -590,9 +581,7 @@ class EmojiPickerListEntry extends StatelessWidget { ImageEmojiDisplay() => ImageEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _emojiSize, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ab5ad446db..a1dea0dff8 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -10,7 +10,6 @@ import 'app.dart'; import 'app_bar.dart'; import 'button.dart'; import 'color.dart'; -import 'content.dart'; import 'icons.dart'; import 'inbox.dart'; import 'inset_shadow.dart'; @@ -23,6 +22,7 @@ import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; enum _HomePageTab { inbox, @@ -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 0f6a5c75a1..341d75bd0e 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,9 +162,13 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } - return SafeArea( - // Don't pad the bottom here; we want the list content to do that. - bottom: false, + if (sections.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#315) add e.g. "You might be interested in recent conversations." + message: zulipLocalizations.inboxEmptyPlaceholder); + } + + return SafeArea( // horizontal insets child: StickyHeaderListView.builder( itemCount: sections.length, itemBuilder: (context, index) { @@ -319,7 +325,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 +393,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), diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..9d439ffdd3 --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,428 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +import '../model/content.dart'; +import '../model/katex.dart'; +import 'content.dart'; + +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: const []); +} + +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, + required this.textStyle, + required this.nodes, + }); + + final TextStyle textStyle; + final List nodes; + + @override + Widget build(BuildContext context) { + Widget widget = _KatexNodeList(nodes: nodes); + + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: mkBaseKatexTextStyle(textStyle).copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + child: widget)); + } +} + +class _KatexNodeList extends StatelessWidget { + const _KatexNodeList({required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return Text.rich(TextSpan( + children: List.unmodifiable(nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + // Work around a bug where text inside a WidgetSpan could be scaled + // multiple times incorrectly, if the system font scale is larger + // than 1x. + // See: https://github.com/flutter/flutter/issues/126962 + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + })); + })))); + } +} + +class _KatexSpan extends StatelessWidget { + const _KatexSpan(this.node); + + final KatexSpanNode node; + + @override + Widget build(BuildContext context) { + var em = DefaultTextStyle.of(context).style.fontSize!; + + Widget widget = const SizedBox.shrink(); + if (node.text != null) { + widget = Text(node.text!); + } else if (node.nodes != null && node.nodes!.isNotEmpty) { + widget = _KatexNodeList(nodes: node.nodes!); + } + + final styles = node.styles; + + // Currently, we expect `top` to be only present with the + // vlist inner row span, and parser handles that explicitly. + assert(styles.topEm == null); + + final fontFamily = styles.fontFamily; + final fontSize = switch (styles.fontSizeEm) { + double fontSizeEm => fontSizeEm * em, + null => null, + }; + if (fontSize != null) em = fontSize; + + final fontWeight = switch (styles.fontWeight) { + KatexSpanFontWeight.bold => FontWeight.bold, + null => null, + }; + var fontStyle = switch (styles.fontStyle) { + KatexSpanFontStyle.normal => FontStyle.normal, + KatexSpanFontStyle.italic => FontStyle.italic, + null => null, + }; + + TextStyle? textStyle; + if (fontFamily != null || + fontSize != null || + fontWeight != null || + fontStyle != null) { + // TODO(upstream) remove this workaround when upstream fixes the broken + // rendering of KaTeX_Math font with italic font style on Android: + // https://github.com/flutter/flutter/issues/167474 + if (defaultTargetPlatform == TargetPlatform.android && + fontFamily == 'KaTeX_Math') { + fontStyle = FontStyle.normal; + } + + textStyle = TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + fontStyle: fontStyle, + ); + } + final textAlign = switch (styles.textAlign) { + KatexSpanTextAlign.left => TextAlign.left, + KatexSpanTextAlign.center => TextAlign.center, + KatexSpanTextAlign.right => TextAlign.right, + null => null, + }; + + if (textStyle != null || textAlign != null) { + widget = DefaultTextStyle.merge( + style: textStyle, + textAlign: textAlign, + child: widget); + } + + widget = SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget); + + final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { + (null, null) => null, + (null, final marginRightEm?) => + EdgeInsets.only(right: marginRightEm * em), + (final marginLeftEm?, null) => + EdgeInsets.only(left: marginLeftEm * em), + (final marginLeftEm?, final marginRightEm?) => + EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), + }; + + if (margin != null) { + assert(margin.isNonNegative); + widget = Padding(padding: margin, child: widget); + } + + return widget; + } +} + +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 5b51d3e909..0a1fe78feb 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:video_player/video_player.dart'; import '../api/core.dart'; @@ -12,8 +11,10 @@ import '../model/binding.dart'; import 'actions.dart'; import 'content.dart'; import 'dialog.dart'; +import 'message_list.dart'; import 'page.dart'; import 'store.dart'; +import 'user.dart'; /// Identifies which [LightboxHero]s should match up with each other /// to produce a hero animation. @@ -166,6 +167,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); final appBarBackgroundColor = Colors.grey.shade900.withValues(alpha: 0.87); @@ -174,11 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" - final timestampText = DateFormat - .yMMMd(/* TODO(#278): Pass selected language here, I think? */) - .add_Hms() - .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); + final timestampText = MessageTimestampStyle.lightbox + .format(widget.message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); // We use plain [AppBar] instead of [ZulipAppBar], even though this page // has a [PerAccountStore], because: @@ -194,13 +197,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)), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dcd063a62a..e3b1d69d6e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,10 +1,16 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; +import '../model/database.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -12,6 +18,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 +31,12 @@ import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; +import 'user.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { static final light = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), labelTime: const HSLColor.fromAHSL(0.49, 0, 0, 0).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), @@ -46,7 +54,6 @@ class MessageListTheme extends ThemeExtension { ); static final dark = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), labelTime: const HSLColor.fromAHSL(0.5, 0, 0, 1).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), @@ -63,7 +70,6 @@ class MessageListTheme extends ThemeExtension { ); MessageListTheme._({ - required this.bgMessageRegular, required this.dmRecipientHeaderBg, required this.labelTime, required this.senderBotIcon, @@ -82,7 +88,6 @@ class MessageListTheme extends ThemeExtension { return extension!; } - final Color bgMessageRegular; final Color dmRecipientHeaderBg; final Color labelTime; final Color senderBotIcon; @@ -92,7 +97,6 @@ class MessageListTheme extends ThemeExtension { @override MessageListTheme copyWith({ - Color? bgMessageRegular, Color? dmRecipientHeaderBg, Color? labelTime, Color? senderBotIcon, @@ -101,7 +105,6 @@ class MessageListTheme extends ThemeExtension { Color? unreadMarkerGap, }) { return MessageListTheme._( - bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg, labelTime: labelTime ?? this.labelTime, senderBotIcon: senderBotIcon ?? this.senderBotIcon, @@ -117,7 +120,6 @@ class MessageListTheme extends ThemeExtension { return this; } return MessageListTheme._( - bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, labelTime: Color.lerp(labelTime, other.labelTime, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, @@ -144,15 +146,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, + /// if there is a [MessageListPage] ancestor, else null. + /// + /// 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? maybeRevealedMutedMessagesOf(BuildContext context) { + final state = + context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>() + ?.state; + return state; } /// The [MessageListPageState] above this context in the tree. @@ -168,9 +206,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 { @@ -185,6 +250,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(); @@ -199,56 +286,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 = - 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; - 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 @@ -256,7 +306,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. @@ -276,11 +327,147 @@ 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); + return IconButton( + icon: const Icon(ZulipIcons.topics), + tooltip: zulipLocalizations.topicsButtonTooltip, + onPressed: () => Navigator.push(context, + TopicListPage.buildRoute(context: context, + streamId: streamId))); } } @@ -297,9 +484,18 @@ class MessageListAppBarTitle extends StatelessWidget { Widget _buildStreamRow(BuildContext context, { ZulipStream? stream, }) { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + // A null [Icon.icon] makes a blank space. - final icon = stream != null ? iconDataForStream(stream) : null; + IconData? icon; + Color? iconColor; + if (stream != null) { + icon = iconDataForStream(stream); + iconColor = colorSwatchFor(context, store.subscriptions[stream.streamId]) + .iconOnBarBackground; + } + return Row( mainAxisSize: MainAxisSize.min, // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. @@ -307,7 +503,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)), @@ -394,7 +590,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, @@ -414,10 +610,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; @@ -438,16 +726,26 @@ 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 { + final GlobalKey _scrollViewKey = GlobalKey(); + MessageListView get model => _model!; MessageListView? _model; @@ -463,8 +761,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages + final anchor = _model == null ? widget.initAnchor : _model!.anchor; _model?.dispose(); - _initModel(PerAccountStoreWidget.of(context)); + _initModel(PerAccountStoreWidget.of(context), anchor); } @override @@ -475,13 +774,39 @@ class _MessageListState extends State with PerAccountStoreAwareStat super.dispose(); } - void _initModel(PerAccountStore store) { - _model = MessageListView.init(store: store, narrow: widget.narrow); + void _initModel(PerAccountStore store, Anchor anchor) { + var narrow = widget.narrow; + if (narrow is TopicNarrow) { + // Normalize topic name. See #1717. + narrow = TopicNarrow(narrow.streamId, + store.processTopicLikeServer(narrow.topic), + with_: narrow.with_); + if (narrow != widget.narrow) { + SchedulerBinding.instance.scheduleFrameCallback((_) { + widget.onNarrowChanged(narrow); + }); + } + } + _model = MessageListView.init(store: store, + narrow: narrow, anchor: anchor); model.addListener(_modelChanged); model.fetchInitial(); } + bool _prevFetched = false; + void _modelChanged() { + // 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 @@ -490,13 +815,136 @@ class _MessageListState extends State with PerAccountStoreAwareStat // redirected us to the new location of the operand message ID. 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 { @@ -512,6 +960,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // still not yet updated to account for the newly-added messages. model.fetchOlder(); } + if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) { + model.fetchNewer(); + } } void _scrollChanged() { @@ -531,8 +982,21 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { + 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( // Don't let this be the place we pad the bottom inset. When there's @@ -562,6 +1026,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // MessageList's dartdoc. child: SafeArea( child: ScrollToBottomButton( + model: model, scrollController: scrollController, visible: _scrollToBottomVisible))), ]))))); @@ -611,7 +1076,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat final itemIndex = totalItems - 1 - (childIndex + bottomItems); final data = model.items[itemIndex]; - final item = _buildItem(data); + final item = _buildItem(data, isLastInFeed: itemIndex == totalItems - 1); return item; })); @@ -645,19 +1110,13 @@ class _MessageListState extends State with PerAccountStoreAwareStat 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(data); + return _buildItem(data, isLastInFeed: itemIndex == totalItems - 1); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { @@ -667,6 +1126,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 @@ -690,19 +1151,37 @@ class _MessageListState extends State with PerAccountStoreAwareStat } Widget _buildStartCap() { - // These assertions are invariants of [MessageListView]. - assert(!(model.fetchingOlder && model.fetchOlderCoolingDown)); - final effectiveFetchingOlder = - model.fetchingOlder || model.fetchOlderCoolingDown; - assert(!(model.haveOldest && effectiveFetchingOlder)); - return switch ((effectiveFetchingOlder, model.haveOldest)) { - (true, _) => const _MessageListLoadingMore(), - (_, true) => const _MessageListHistoryStart(), - (_, _) => const SizedBox.shrink(), - }; + // 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/channel/48-mobile/topic/space.20at.20end.20of.20thread/near/2203391 + const SizedBox(height: 12), + ]); + } 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) { + Widget _buildItem(MessageListItem data, {required bool isLastInFeed}) { switch (data) { case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); @@ -717,8 +1196,16 @@ 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, + isLastInFeed: isLastInFeed, + item: data); + case MessageListOutboxMessageItem(): + final header = RecipientHeader(message: data.message, narrow: widget.narrow); + return MessageItem( + narrow: widget.narrow, header: header, - trailingWhitespace: 11, + isLastInFeed: isLastInFeed, item: data); } } @@ -750,13 +1237,40 @@ class _MessageListLoadingMore extends StatelessWidget { } 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 @@ -818,13 +1332,14 @@ class _TypingStatusWidgetState extends State with PerAccount final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); - if (typistIds.isEmpty) return const SizedBox(); - final text = switch (typistIds.length) { + final filteredTypistIds = typistIds.whereNot(store.isUserMuted); + if (filteredTypistIds.isEmpty) return const SizedBox(); + final text = switch (filteredTypistIds.length) { 1 => zulipLocalizations.onePersonTyping( - store.userDisplayName(typistIds.first)), + store.userDisplayName(filteredTypistIds.first)), 2 => zulipLocalizations.twoPeopleTyping( - store.userDisplayName(typistIds.first), - store.userDisplayName(typistIds.last)), + store.userDisplayName(filteredTypistIds.first), + store.userDisplayName(filteredTypistIds.last)), _ => zulipLocalizations.manyPeopleTyping, }; @@ -862,15 +1377,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 @@ -981,13 +1496,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: [ @@ -1015,27 +1529,36 @@ class DateSeparator extends StatelessWidget { class MessageItem extends StatelessWidget { const MessageItem({ super.key, + required this.narrow, required this.item, required this.header, - this.trailingWhitespace, + required this.isLastInFeed, }); + final Narrow narrow; final MessageListMessageBaseItem item; final Widget header; - final double? trailingWhitespace; + final bool isLastInFeed; @override Widget build(BuildContext context) { - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); final item = this.item; Widget child = ColoredBox( - color: messageListTheme.bgMessageRegular, + color: designVariables.bgMessageRegular, child: Column(children: [ switch (item) { - MessageListMessageItem() => MessageWithPossibleSender(item: item), + MessageListMessageItem() => MessageWithPossibleSender( + narrow: narrow, + item: item), + MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), }, - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + // TODO write tests for this padding logic + if (isLastInFeed) + const SizedBox(height: 5) + else if (item.isLastInBlock) + const SizedBox(height: 11), ])); if (item case MessageListMessageItem(:final message)) { child = _UnreadMarker( @@ -1098,6 +1621,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return true; case ChannelNarrow(): @@ -1264,7 +1788,7 @@ class DmRecipientHeader extends StatelessWidget { child: Icon( color: designVariables.title, size: 16, - ZulipIcons.user)), + ZulipIcons.two_person)), Expanded( child: Text(title, style: recipientHeaderTextStyle(context), @@ -1317,8 +1841,14 @@ class DateText extends StatelessWidget { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: ZulipBinding.instance.utcNow().toLocal(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations)!; return Text( style: TextStyle( color: messageListTheme.labelTime, @@ -1328,92 +1858,80 @@ class DateText extends StatelessWidget { // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], ), - formatHeaderDate( - zulipLocalizations, - DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); - } -} - -@visibleForTesting -String formatHeaderDate( - ZulipLocalizations zulipLocalizations, - DateTime dateTime, { - required DateTime now, -}) { - assert(!dateTime.isUtc && !now.isUtc, - '`dateTime` and `now` need to be in local time.'); - - if (dateTime.year == now.year && - dateTime.month == now.month && - dateTime.day == now.day) { - return zulipLocalizations.today; - } - - final yesterday = now - .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) - .add(const Duration(days: -1)); - if (dateTime.year == yesterday.year && - dateTime.month == yesterday.month && - dateTime.day == yesterday.day) { - return zulipLocalizations.yesterday; - } - - // If it is Dec 1 and you see a label that says `Dec 2` - // it could be misinterpreted as Dec 2 of the previous - // year. For times in the future, those still on the - // current day will show as today (handled above) and - // any dates beyond that show up with the year. - if (dateTime.year == now.year && dateTime.isBefore(now)) { - return DateFormat.MMMd().format(dateTime); - } else { - return DateFormat.yMMMd().format(dateTime); + formattedTimestamp); } } -// TODO(i18n): web seems to ignore locale in formatting time, but we could do better -final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); +class SenderRow extends StatelessWidget { + const SenderRow({super.key, required this.message, required this.timestampStyle}); -class _SenderRow extends StatelessWidget { - const _SenderRow({required this.message, required this.showTimestamp}); + final MessageBase message; + final MessageTimestampStyle timestampStyle; - final Message 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 + final revealedMutedMessagesState = + MessageListPage.maybeRevealedMutedMessagesOf(context); + // The "unrevealed" state only exists in the message list, + // and we show a sender row in at least one place outside the message list + // (the message action sheet). + if (revealedMutedMessagesState == null) return false; + return !revealedMutedMessagesState.isMutedMessageRevealed(message.id); + } @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); final sender = store.getUser(message.senderId); - final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + final timestamp = timestampStyle + .format(message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); + + final showAsMuted = _showAsMuted(context, store); + return Padding( padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, 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)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( @@ -1423,9 +1941,9 @@ class _SenderRow extends StatelessWidget { ), ], ]))), - if (showTimestamp) ...[ + if (timestamp != null) ...[ const SizedBox(width: 4), - Text(time, + Text(timestamp, style: TextStyle( color: messageListTheme.labelTime, fontSize: 16, @@ -1437,13 +1955,126 @@ class _SenderRow extends StatelessWidget { } } +enum MessageTimestampStyle { + none, + dateOnlyRelative, + timeOnly, + + // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45 + lightbox, + + /// The longest format, with full date and time as numbers, not "Today"/etc. + /// + /// For UI contexts focused just on the one message, + /// or as a tooltip on a shorter-formatted timestamp. + /// + /// The detail won't always be needed, but this format makes mental timezone + /// conversions easier, which is helpful when the user is thinking about + /// business hours on a different continent, + /// or traveling and they know their device timezone setting is wrong, etc. + // TODO(design) show "Today"/etc. after all? Discussion: + // https://github.com/zulip/zulip-flutter/pull/1624#issuecomment-3050296488 + full, + ; + + static String _formatDateOnlyRelative( + DateTime dateTime, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + }) { + assert(!dateTime.isUtc && !now.isUtc, + '`dateTime` and `now` need to be in local time.'); + + if (dateTime.year == now.year && + dateTime.month == now.month && + dateTime.day == now.day) { + return zulipLocalizations.today; + } + + final yesterday = now + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) + .add(const Duration(days: -1)); + if (dateTime.year == yesterday.year && + dateTime.month == yesterday.month && + dateTime.day == yesterday.day) { + return zulipLocalizations.yesterday; + } + + // If it is Dec 1 and you see a label that says `Dec 2` + // it could be misinterpreted as Dec 2 of the previous + // year. For times in the future, those still on the + // current day will show as today (handled above) and + // any dates beyond that show up with the year. + if (dateTime.year == now.year && dateTime.isBefore(now)) { + return DateFormat.MMMd().format(dateTime); + } else { + return DateFormat.yMMMd().format(dateTime); + } + } + + static final _timeFormat12 = DateFormat('h:mm aa'); + static final _timeFormat24 = DateFormat('Hm'); + static final _timeFormatLocaleDefault = DateFormat('jm'); + static final _timeFormat12WithSeconds = DateFormat('h:mm:ss aa'); + static final _timeFormat24WithSeconds = DateFormat('Hms'); + static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); + + static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, + }; + + static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, + }; + + /// Format a [Message.timestamp] for this mode. + // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) + String? format( + int messageTimestamp, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + required TwentyFourHourTimeMode twentyFourHourTimeMode, + }) { + final asDateTime = + DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); + + switch (this) { + case none: return null; + case dateOnlyRelative: + return _formatDateOnlyRelative(asDateTime, + now: now, zulipLocalizations: zulipLocalizations); + case timeOnly: + return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime); + case lightbox: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern) + .format(asDateTime); + case full: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern) + .format(asDateTime); + } + } +} + /// 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}); + const MessageWithPossibleSender({ + super.key, + required this.narrow, + required this.item, + }); + final Narrow narrow; final MessageListMessageItem item; @override @@ -1492,37 +2123,77 @@ class MessageWithPossibleSender extends StatelessWidget { } } + final tapOpensConversation = switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => true, + }; + + final showAsMuted = store.isUserMuted(message.senderId) + && !MessageListPage.maybeRevealedMutedMessagesOf(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 (item.showSender) - _SenderRow(message: message, showTimestamp: true), + SenderRow(message: message, + timestampStyle: MessageTimestampStyle.timeOnly), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ const SizedBox(width: 16), - Expanded(child: 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) - 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), ]), @@ -1552,30 +2223,34 @@ class _EditMessageStatusRow extends StatelessWidget { return switch (status) { // TODO parse markdown and show new content as local echo? - false => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 1.5, - children: [ - Text( + false => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within bottom outer padding: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ])), + true => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreEditMessageGestureDetector( + messageId: messageId, + child: Text( style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntInfo), + .copyWith(color: designVariables.btnLabelAttLowIntDanger), textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditLabel), - // TODO instead place within bottom outer padding: - // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 - LinearProgressIndicator( - minHeight: 2, - color: designVariables.foreground.withValues(alpha: 0.5), - backgroundColor: designVariables.foreground.withValues(alpha: 0.2), - ), - ]), - true => _RestoreEditMessageGestureDetector( - messageId: messageId, - child: Text( - style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntDanger), - textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditFailedLabel)), + zulipLocalizations.savingMessageEditFailedLabel))), }; } } @@ -1595,9 +2270,132 @@ class _RestoreEditMessageGestureDetector extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: () { final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-edit-message from any message-list page if (composeBoxState == null) return; composeBoxState.startEditInteraction(messageId); }, 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, timestampStyle: MessageTimestampStyle.none), + 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..93fbf5c354 --- /dev/null +++ b/lib/widgets/new_dm_sheet.dart @@ -0,0 +1,431 @@ +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 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.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) { + final users = store.allUsers + .where((user) => user.isActive && !store.isUserMuted(user.userId)); + sortedUsers = List.from(users) + ..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)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), + ]))); + } +} + +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.rich( + TextSpan(text: store.userDisplayName(userId), children: [ + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context)), + ]), + 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..9b65831b29 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -3,15 +3,21 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import '../api/route/settings.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; import '../model/content.dart'; import '../model/narrow.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'content.dart'; import 'message_list.dart'; import 'page.dart'; +import 'remote_settings.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -44,15 +50,46 @@ class ProfilePage extends StatelessWidget { return const _ProfileErrorPage(); } - final displayEmail = store.userDisplayEmail(user); + final nameStyle = _TextStyles.primaryFieldText + .merge(weightVariableTextStyle(context, wght: 700)); + + final userStatus = store.getUserStatus(userId); + 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)), + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), + ]), textAlign: TextAlign.center, - style: _TextStyles.primaryFieldText - .merge(weightVariableTextStyle(context, wght: 700))), + style: nameStyle), + if (userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -60,10 +97,15 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time + if (!store.realmPresenceDisabled && userId == store.selfUserId) ...[ + const SizedBox(height: 16), + _InvisibleModeToggle(), + const SizedBox(height: 16), + ], + _ProfileDataTable(profileData: user.profileData), const SizedBox(height: 16), FilledButton.icon( @@ -75,7 +117,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( @@ -88,6 +132,35 @@ class ProfilePage extends StatelessWidget { } } +class _InvisibleModeToggle extends StatelessWidget { + const _InvisibleModeToggle(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + + return MenuButtonsShape(buttons: [ + // `value: true` means invisible mode is on, + // i.e., that presenceEnabled is false. + RemoteSettingBuilder( + findValueInStore: (store) => !store.userSettings.presenceEnabled, + sendValueToServer: (value) => updateSettings(store.connection, + newSettings: {UserSettingName.presenceEnabled: !value}), + // TODO(#741) interpret API errors for user + onError: (e, requestedValue) => reportErrorToUserBriefly( + requestedValue + ? zulipLocalizations.turnOnInvisibleModeErrorTitle + : zulipLocalizations.turnOffInvisibleModeErrorTitle), + builder: (value, handleRequestNewValue) => ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: zulipLocalizations.invisibleMode, + onPressed: () => handleRequestNewValue(!value), + toggle: Toggle(value: value, onChanged: handleRequestNewValue))), + ]); + } +} + class _ProfileErrorPage extends StatelessWidget { const _ProfileErrorPage(); diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..f4846bf943 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,14 +1,18 @@ 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'; +import 'user.dart'; class RecentDmConversationsPageBody extends StatefulWidget { const RecentDmConversationsPageBody({super.key}); @@ -48,19 +52,38 @@ 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/remote_settings.dart b/lib/widgets/remote_settings.dart new file mode 100644 index 0000000000..f2dfdb3ebe --- /dev/null +++ b/lib/widgets/remote_settings.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../basic.dart'; +import '../model/store.dart'; +import 'store.dart'; + +/// A builder function for [RemoteSettingBuilder.builder] +/// that creates a toggle, or radio buttons, etc. +/// +/// [value] is the value, whether from [RemoteSettingBuilder.findValueInStore] +/// or from local echo. +/// +/// [handleRequestNewValue] calls [RemoteSettingBuilder.sendValueToServer] +/// and starts or extends a local-echo period. +/// If extending a local-echo period, it replaces the old local-echo value. +/// +/// [handleRequestNewValue] may be called any time. +/// API requests are not debounced, +/// and the server may handle them out of order. +/// But the local echo minimizes flickering (see [localEchoMinimum]) +/// while ensuring that the real in-store value is shown soon after the +/// user finishes interacting, whether the request(s) succeeded or failed. +typedef RemoteSettingBuilderFn = + Widget Function(T value, void Function(T) handleRequestNewValue); + +/// A stateful builder widget for toggles/etc. +/// that control per-account settings on the server, +/// with time-bounded local echo. +/// +/// Specify the setting with [findValueInStore] and [sendValueToServer]. +/// +/// [builder] should use its value and change-handler params +/// instead of calling the store and API directly. +/// +/// When called, [builder]'s [RemoteSettingBuilderFn.handleRequestNewValue] +/// starts or extends a local-echo period. +/// During local echo, [builder] is passed the new value +/// instead of the value in the store. +/// Local echo will continue for at least [localEchoMinimum] +/// after the current call. After that, it may end +/// - because the [findValueInStore] value changed after this call +/// (i.e. the event arrived), or +/// - because [sendValueToServer] failed, or +/// - because [localEchoIdleTimeout] elapsed and there wasn't another call. +class RemoteSettingBuilder extends StatefulWidget { + const RemoteSettingBuilder({ + super.key, + required this.findValueInStore, + required this.sendValueToServer, + this.onError, + required this.builder, + }); + + final T Function(PerAccountStore) findValueInStore; + final Future Function(T) sendValueToServer; + final void Function(Object? e, T requestedValue)? onError; + final RemoteSettingBuilderFn builder; + + /// The minimum time to spend in local echo, + /// chosen to minimize flickers that are not caused by user input. + /// + /// The common case is when the API request fails quickly. + /// + /// (Another case is when spam-tapping a toggle switch, + /// if a user wants to do that. + /// The timer resets on [RemoteSettingBuilderFn.handleRequestNewValue], + /// so until the spam-taps are finished, the switch responds only to the taps, + /// not to the event stream. + /// Then when the taps stop, it settles to the value from the latest event.) + static final Duration localEchoMinimum = Duration(seconds: 1); + + static final Duration localEchoIdleTimeout = Duration(seconds: 3); + + @override + State> createState() => _RemoteSettingBuilderState(); +} + +class _RemoteSettingBuilderState extends State> with PerAccountStoreAwareStateMixin> { + final _LocalEchoNotifier _notifier = _LocalEchoNotifier(); + + @override + void initState() { + super.initState(); + _notifier.addListener(_notifierChanged); + } + + late T? _prevValueFromStore; + + @override + void onNewStore() { + _prevValueFromStore = widget.findValueInStore(PerAccountStoreWidget.of(context)); + _notifier.stop(); + } + + @override + void didChangeDependencies() { + // On the first call, this sets _prevValueFromStore, via onNewStore. + super.didChangeDependencies(); + + final value = widget.findValueInStore(PerAccountStoreWidget.of(context)); + if (value != _prevValueFromStore) { + _notifier.stop(); + _prevValueFromStore = value; + } + } + + bool _disposed = false; + + @override + void dispose() { + _notifier.dispose(); + _disposed = true; + super.dispose(); + } + + void _notifierChanged() { + setState(() { + // The actual state lives in _notifier. + }); + } + + void _handleRequestNewValue(T value) async { + _notifier.startOrExtend(value); + + try { + await widget.sendValueToServer(value); + if (_disposed) return; + // Don't call _notifier.stop(). We do that when the event arrives, + // causing the in-store value to change (see didChangeDependencies). + } catch (e) { // TODO(log) + if (_disposed) return; + await _notifier.stop(); + if (_disposed) return; + if (widget.onError != null) { + widget.onError!(e, value); + } + } + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + final value = _notifier.value.orElse(() => widget.findValueInStore(store)); + return widget.builder(value, _handleRequestNewValue); + } +} + +/// A [ValueNotifier] for whether local echo is active, and with what value. +/// +/// The [ValueNotifier.value] is an [Option]. +/// When it is [OptionSome], local echo is active with [OptionSome.value]. +/// When it is [OptionNone], local echo is not active. +/// +/// Use [startOrExtend] and [stop] to control local echo. +class _LocalEchoNotifier extends ValueNotifier> { + _LocalEchoNotifier() : super(OptionNone()); + + Timer? _lowerBoundTimer; + Completer? _lowerBoundCompleter; + Timer? _upperBoundTimer; + + /// Start a local-echo session or extend the timers of an existing session. + void startOrExtend(T newValue) { + value = OptionSome(newValue); + + _lowerBoundCompleter ??= Completer(); + _lowerBoundTimer?.cancel(); + _lowerBoundTimer = Timer(RemoteSettingBuilder.localEchoMinimum, () { + _lowerBoundCompleter!.complete(); + _lowerBoundCompleter = null; + }); + + _upperBoundTimer?.cancel(); + _upperBoundTimer = Timer(RemoteSettingBuilder.localEchoIdleTimeout, () { + value = OptionNone(); + }); + } + + /// Request that a local-echo session, if any, be stopped as soon as possible. + /// + /// The session will be stopped either immediately or + /// [RemoteSettingBuilder.localEchoMinimum] after the last [startOrExtend] call, + /// whichever is later. + /// + /// The returned [Future] resolves when the session is stopped. + Future stop() async { + if (_lowerBoundCompleter != null) { + await _lowerBoundCompleter!.future; + if (_disposed) return; + } + value = OptionNone(); + } + + bool _disposed = false; + + @override + void dispose() { + _lowerBoundCompleter?.complete(); + _lowerBoundTimer?.cancel(); + _upperBoundTimer?.cancel(); + _disposed = true; + super.dispose(); + } +} diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 9e7581c539..5995cdcbfe 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), @@ -44,18 +46,19 @@ class _ThemeSetting extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final globalSettings = GlobalStoreWidget.settingsOf(context); - return Column( - children: [ - ListTile(title: Text(zulipLocalizations.themeSettingTitle)), - for (final themeSettingOption in [null, ...ThemeSetting.values]) - RadioListTile.adaptive( - title: Text(ThemeSetting.displayName( - themeSetting: themeSettingOption, - zulipLocalizations: zulipLocalizations)), - value: themeSettingOption, - groupValue: globalSettings.themeSetting, - onChanged: (newValue) => _handleChange(context, newValue)), - ]); + return RadioGroup( + groupValue: globalSettings.themeSetting, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column( + children: [ + ListTile(title: Text(zulipLocalizations.themeSettingTitle)), + for (final themeSettingOption in [null, ...ThemeSetting.values]) + RadioListTile.adaptive( + title: Text(ThemeSetting.displayName( + themeSetting: themeSettingOption, + zulipLocalizations: zulipLocalizations)), + value: themeSettingOption), + ])); } } @@ -82,6 +85,146 @@ 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: RadioGroup( + groupValue: globalSettings.visitFirstUnread, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value), + ]))); + } +} + +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: RadioGroup( + groupValue: globalSettings.markReadOnScroll, + onChanged: (newValue) => _handleChange(context, newValue), + child: 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), + ]))); + } +} + class ExperimentalFeaturesPage extends StatelessWidget { const ExperimentalFeaturesPage({super.key}); diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index ab287b745a..7278305b29 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.placeholder = const LoadingPlaceholder(), + this.blockingFuture, + this.placeholder = const BlankLoadingPlaceholder(), 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; }); @@ -330,6 +341,15 @@ class _PerAccountStoreInheritedWidget extends InheritedNotifier store != oldWidget.store; } +class BlankLoadingPlaceholder extends StatelessWidget { + const BlankLoadingPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} + class LoadingPlaceholder extends StatelessWidget { const LoadingPlaceholder({super.key}); diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 062bb9743e..d64c578c5e 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,15 @@ class _SubscriptionListPageBodyState extends State wit _sortSubs(pinned); _sortSubs(unpinned); - return SafeArea( - // Don't pad the bottom here; we want the list content to do that. - bottom: false, + 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( // horizontal insets child: CustomScrollView( slivers: [ - if (pinned.isEmpty && unpinned.isEmpty) - const _NoSubscriptionsItem(), if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), @@ -111,34 +114,10 @@ class _SubscriptionListPageBodyState extends State wit ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe - - // This ensures last item in scrollable can settle in an unobstructed area. - const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), ])); } } -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 276e308b2b..8e70f28e3c 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -137,6 +137,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), bgMenuButtonSelected: Colors.white, + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), @@ -153,27 +154,54 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), + contextMenuItemIcon: const Color(0xff4f42c9), contextMenuItemLabel: const Color(0xff242631), 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), + listMenuItemBg: const Color(0xffcbcdd6), + listMenuItemIcon: const Color(0xff9194a3), + listMenuItemText: const Color(0xff2d303c), + + // Keep the color here and the corresponding non-dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. 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), @@ -185,6 +213,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -197,6 +226,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), @@ -213,30 +243,57 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), + contextMenuItemIcon: const Color(0xff9398fd), contextMenuItemLabel: const Color(0xffdfe1e8), 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), + listMenuItemBg: const Color(0xff2d303c), + listMenuItemIcon: const Color(0xff767988), + listMenuItemText: const Color(0xffcbcdd6), + + // Keep the color here and the corresponding dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. 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), @@ -253,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -265,6 +324,7 @@ class DesignVariables extends ThemeExtension { required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, @@ -281,27 +341,45 @@ class DesignVariables extends ThemeExtension { required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, + required this.contextMenuItemIcon, required this.contextMenuItemLabel, required this.contextMenuItemMeta, 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.listMenuItemBg, + required this.listMenuItemIcon, + required this.listMenuItemText, 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, @@ -313,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -334,6 +413,7 @@ class DesignVariables extends ThemeExtension { final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; @@ -350,31 +430,49 @@ class DesignVariables extends ThemeExtension { final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; + final Color contextMenuItemIcon; final Color contextMenuItemLabel; 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 listMenuItemBg; + final Color listMenuItemIcon; + final Color listMenuItemText; 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) @@ -386,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -398,6 +497,7 @@ class DesignVariables extends ThemeExtension { Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, @@ -414,27 +514,45 @@ class DesignVariables extends ThemeExtension { Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, + Color? contextMenuItemIcon, Color? contextMenuItemLabel, 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? listMenuItemBg, + Color? listMenuItemIcon, + Color? listMenuItemText, 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, @@ -446,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -457,6 +576,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, + bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, @@ -473,27 +593,45 @@ class DesignVariables extends ThemeExtension { composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, + contextMenuItemIcon: contextMenuItemIcon ?? this.contextMenuItemIcon, contextMenuItemLabel: contextMenuItemLabel ?? this.contextMenuItemLabel, contextMenuItemMeta: contextMenuItemMeta ?? this.contextMenuItemMeta, 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, + listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, + listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, + listMenuItemText: listMenuItemText ?? this.listMenuItemText, 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, @@ -505,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -523,6 +662,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, + bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, @@ -539,27 +679,45 @@ class DesignVariables extends ThemeExtension { composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, + contextMenuItemIcon: Color.lerp(contextMenuItemIcon, other.contextMenuItemIcon, t)!, contextMenuItemLabel: Color.lerp(contextMenuItemLabel, other.contextMenuItemLabel, t)!, contextMenuItemMeta: Color.lerp(contextMenuItemMeta, other.contextMenuItemMeta, t)!, 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)!, + listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, + listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, + listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, 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)!, @@ -571,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/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/lib/widgets/user.dart b/lib/widgets/user.dart new file mode 100644 index 0000000000..9406580fb2 --- /dev/null +++ b/lib/widgets/user.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/avatar_url.dart'; +import '../model/binding.dart'; +import '../model/emoji.dart'; +import '../model/presence.dart'; +import 'content.dart'; +import 'emoji.dart'; +import 'icons.dart'; +import 'store.dart'; +import 'theme.dart'; + +/// A rounded square with size [size] showing a user's avatar. +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + 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, + backgroundColor: backgroundColor, + userIdForPresence: showPresence ? userId : null, + child: AvatarImage(userId: userId, size: size, replaceIfMuted: replaceIfMuted)); + } +} + +/// The appropriate avatar image for a user ID. +/// +/// If the user isn't found, gives a [SizedBox.shrink]. +/// +/// Wrap this with [AvatarShape]. +class AvatarImage extends StatelessWidget { + const AvatarImage({ + 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) { + final store = PerAccountStoreWidget.of(context); + final user = store.getUser(userId); + + if (user == null) { // TODO(log) + 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), + }; + + if (resolvedUrl == null) { + return const SizedBox.shrink(); + } + + final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); + final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); + + return RealmContentNetworkImage( + avatarUrl.get(physicalSize), + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + ); + } +} + +/// 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) { + // (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))); + } +} + +/// A user status emoji to be displayed in different parts of the app. +/// +/// Use [padding] to control the padding of status emoji from neighboring +/// widgets. +/// When there is no status emoji to be shown, the padding will be omitted too. +/// +/// Use [neverAnimate] to forcefully disable the animation for animated emojis. +/// Defaults to true. +class UserStatusEmoji extends StatelessWidget { + const UserStatusEmoji({ + super.key, + required this.userId, + required this.size, + this.padding = EdgeInsets.zero, + this.neverAnimate = true, + }); + + final int userId; + final double size; + final EdgeInsetsGeometry padding; + final bool neverAnimate; + + static const double _spanPadding = 4; + + /// Creates a [WidgetSpan] with a [UserStatusEmoji], for use in rich text; + /// before or after a text span. + /// + /// Use [position] to tell the emoji span where it is located relative to + /// another span, so that it can adjust the necessary padding from it. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + StatusEmojiPosition position = StatusEmojiPosition.after, + bool neverAnimate = true, + }) { + final (double paddingStart, double paddingEnd) = switch (position) { + StatusEmojiPosition.before => (0, _spanPadding), + StatusEmojiPosition.after => (_spanPadding, 0), + }; + final size = textScaler.scale(fontSize); + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: UserStatusEmoji(userId: userId, size: size, + padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), + neverAnimate: neverAnimate)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final emoji = store.getUserStatus(userId).emoji; + + final placeholder = SizedBox.shrink(); + if (emoji == null) return placeholder; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: emoji.reactionType, + emojiCode: emoji.emojiCode, + emojiName: emoji.emojiName) + // Web doesn't seem to respect the emojiset user settings for user status. + // .resolve(store.userSettings) + ; + return switch (emojiDisplay) { + UnicodeEmojiDisplay() => Padding( + padding: padding, + child: UnicodeEmojiWidget(size: size, emojiDisplay: emojiDisplay)), + ImageEmojiDisplay() => Padding( + padding: padding, + child: ImageEmojiWidget( + size: size, + emojiDisplay: emojiDisplay, + neverAnimate: neverAnimate, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder)), + // The user-status feature doesn't support a :text_emoji:-style display. + // Also, if an image emoji's URL string doesn't parse, it'll fall back to + // a :text_emoji:-style display. We show nothing for this case. + TextEmojiDisplay() => placeholder, + }; + } +} + +/// The position of the status emoji span relative to another text span. +enum StatusEmojiPosition { before, after } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index bb5fd1a927..683a8b0863 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -7,38 +7,38 @@ 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.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (~> 11.10.0) + - FirebaseMessaging (~> 11.15.0) + - firebase_core (3.15.1): + - Firebase/CoreOnly (~> 11.15.0) - FlutterMacOS - - firebase_messaging (15.2.5): - - Firebase/CoreOnly (~> 11.10.0) - - Firebase/Messaging (~> 11.10.0) + - firebase_messaging (15.2.9): + - Firebase/CoreOnly (~> 11.15.0) + - Firebase/Messaging (~> 11.15.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.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.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): @@ -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,13 +175,13 @@ 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 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_core: 8dc569d17b3a9fc3ee5ebc21b322411b4a796833 + firebase_messaging: adaf7fc22897a7aa49410d15f8a595bef2dbca2d + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 @@ -190,8 +190,8 @@ SPEC CHECKSUMS: 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 4784adf19a..e693e77f23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "82.0.0" + version: "85.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 + sha256: a5788040810bd84400bc209913fbc40f388cded7cdf95ee2f5d2bff7e38d5241 url: "https://pub.dev" source: hosted - version: "1.3.54" + version: "1.3.58" analyzer: dependency: transitive description: name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + sha256: "01949bf52ad33f0e0f74f881fbaac4f348c556531951d92c8d16f1262aa19ff8" url: "https://pub.dev" source: hosted - version: "7.4.5" + version: "7.5.4" app_settings: dependency: "direct main" description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.4" build_config: dependency: transitive description: @@ -85,26 +85,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.5.4" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.1.2" built_collection: dependency: transitive 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,18 +141,18 @@ 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: name: checks - sha256: aad431b45a8ae2fa26db8c22e385b9cdec73f72986a1d9d9f2017f4c39ecf5c9 + sha256: "016871c84732c1ac9856b8940236d5a5802ba638b3bd3e0ea7027b51a35f7aa7" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" cli_config: dependency: transitive description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 url: "https://pub.dev" source: hosted - version: "1.13.1" + 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,34 +262,34 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "0c6396126421b590089447154c5f98a5de423b70cfb15b1578fd018843ee6f53" + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" url: "https://pub.dev" source: hosted - version: "11.4.0" + version: "11.5.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.0.3" drift: dependency: "direct main" description: name: drift - sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85 + sha256: e60c715f045dd33624fc533efb0075e057debec9f39e83843e518f488a0e21fb url: "https://pub.dev" source: hosted - version: "2.26.1" + version: "2.27.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816" + sha256: "7ad88b8982e753eadcdbc0ea7c7d30500598af733601428b5c9d264baf5106d6" url: "https://pub.dev" source: hosted - version: "2.26.1" + version: "2.27.0" fake_async: dependency: "direct dev" description: @@ -318,10 +318,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" + sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a url: "https://pub.dev" source: hosted - version: "10.1.9" + 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,50 +358,50 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" + sha256: c6e8a6bf883d8ddd0dec39be90872daca65beaa6f4cff0051ed3b16c56b82e9f url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.15.1" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: d7253d255ff10f85cfd2adaba9ac17bae878fa3ba577462451163bd9f1d1f0bf + sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "2.24.1" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "5f8918848ee0c8eb172fc7698619b2bcd7dda9ade8b93522c6297dd8f9178356" + sha256: "0f3363f97672eb9f65609fa00ed2f62cc8ec93e7e2d4def99726f9165d3d8a73" url: "https://pub.dev" source: hosted - version: "15.2.5" + version: "15.2.9" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "0bbea00680249595fc896e7313a2bd90bd55be6e0abbe8b9a39d81b6b306acb6" + sha256: "7a05ef119a14c5f6a9440d1e0223bcba20c8daf555450e119c4c477bf2c3baa9" url: "https://pub.dev" source: hosted - version: "4.6.5" + version: "4.6.9" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: ffb392ce2a7e8439cd0a9a80e3c702194e73c927e5c7b4f0adf6faa00b245b17 + sha256: a4547f76da2a905190f899eb4d0150e1d0fd52206fce469d9f05ae15bb68b2c5 url: "https://pub.dev" source: hosted - version: "3.10.5" + version: "3.10.9" 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 @@ -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: @@ -834,10 +834,10 @@ packages: dependency: "direct dev" description: name: pigeon - sha256: "3e4e6258f22760fa11f86d2a5202fb3f8367cb361d33bd9a93de85a7959e9976" + sha256: "5889b1a88204ed646229447a898e8c3389a8f906b087ea1d3d4b09338730b3c9" url: "https://pub.dev" source: hosted - version: "25.3.1" + version: "25.5.0" platform: dependency: transitive description: @@ -847,7 +847,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: "direct dev" + dependency: transitive description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -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: @@ -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: @@ -1191,42 +1191,42 @@ 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: name: video_player - sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14" + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" url: "https://pub.dev" source: hosted - version: "2.9.5" + version: "2.10.0" video_player_android: dependency: transitive description: name: video_player_android - sha256: "1f4e8e0e02403452d699ef7cf73fe9936fac8f6f0605303d111d71acb375d1bc" + sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" url: "https://pub.dev" source: hosted - version: "2.8.3" + version: "2.8.7" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" + sha256: "0d47db6cbf72db61d86369219efd35c7f9d93515e1319da941ece81b1f21c49c" url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "2.7.2" video_player_platform_interface: dependency: "direct dev" description: name: video_player_platform_interface - sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" video_player_web: dependency: transitive description: @@ -1239,10 +1239,10 @@ packages: 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: @@ -1263,10 +1263,10 @@ packages: 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: @@ -1311,10 +1311,10 @@ packages: dependency: transitive description: name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.13.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-114.0.dev <4.0.0" - flutter: ">=3.33.0-1.0.pre.44" + dart: ">=3.9.0-293.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.832" diff --git a/pubspec.yaml b/pubspec.yaml index 1df9ed3390..cb108fa57e 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.29+29 +version: 30.0.262+262 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-114.0.dev <4.0.0' - flutter: '>=3.33.0-1.0.pre.44' # 358b0726882869cd917e1e2dc07b6c298e6c2992 + sdk: '>=3.9.0-293.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.832' # d35bde5363d5e25b71d69a81e8c93b0ee3272609 # To update dependencies, see instructions in README.md. dependencies: @@ -61,7 +61,7 @@ dependencies: 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.10.0 wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin @@ -98,12 +98,11 @@ 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 pigeon: ^25.3.1 - plugin_platform_interface: ^2.1.8 stack_trace: ^1.11.1 test: ^1.23.1 test_api: ^0.7.3 @@ -115,6 +114,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/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index b90238ae35..201a0ef7b6 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -1,6 +1,31 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/basic.dart'; + +extension UserStatusChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get emoji => has((x) => x.emoji, 'emoji'); +} + +extension StatusEmojiChecks on Subject { + Subject get emojiName => has((x) => x.emojiName, 'emojiName'); + Subject get emojiCode => has((x) => x.emojiCode, 'emojiCode'); + Subject get reactionType => has((x) => x.reactionType, 'reactionType'); +} + +extension UserStatusChangeChecks on Subject { + Subject> get text => has((x) => x.text, 'text'); + Subject> get emoji => has((x) => x.emoji, 'emoji'); +} + +extension UserGroupChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get name => has((x) => x.name, 'name'); + Subject get description => has((x) => x.description, 'description'); + Subject get isSystemGroup => has((x) => x.isSystemGroup, 'isSystemGroup'); + Subject get deactivated => has((x) => x.deactivated, 'deactivated'); +} extension UserChecks on Subject { Subject get userId => has((x) => x.userId, 'userId'); @@ -37,9 +62,15 @@ extension TopicNameChecks on Subject { } extension StreamConversationChecks on Subject { + Subject get streamId => has((x) => x.streamId, 'streamId'); + Subject get topic => has((x) => x.topic, 'topic'); Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } +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'); diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 6012f29ead..8717ebbb6e 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import '../../example_data.dart' as eg; import '../../stdlib_checks.dart'; @@ -25,6 +26,71 @@ void main() { }); }); + test('UserStatusChange', () { + void doCheck({ + required (String? statusText, String? emojiName, + String? emojiCode, String? reactionType) incoming, + required (Option text, Option emoji) expected, + }) { + check(UserStatusChange.fromJson({ + 'status_text': incoming.$1, + 'emoji_name': incoming.$2, + 'emoji_code': incoming.$3, + 'reaction_type': incoming.$4, + })) + ..text.equals(expected.$1) + ..emoji.equals(expected.$2); + } + + doCheck( + incoming: ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome('Busy'), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: (null, 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionNone(), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('Busy', '', '', ''), + expected: (OptionSome('Busy'), OptionSome(null))); + + doCheck( + incoming: ('Busy', null, null, null), + expected: (OptionSome('Busy'), OptionNone())); + + doCheck( + incoming: ('', '', '', ''), + expected: (OptionSome(null), OptionSome(null))); + + doCheck( + incoming: (null, null, null, null), + expected: (OptionNone(), OptionNone())); + + // For the API quirk when `reaction_type` is 'unicode_emoji' when the + // emoji is cleared. + doCheck( + incoming: ('', '', '', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(null))); + + // Hardly likely to happen from the API standpoint, but we handle it anyway. + doCheck( + incoming: (null, null, null, 'unicode_emoji'), + expected: (OptionNone(), OptionNone())); + }); + group('User', () { final Map baseJson = Map.unmodifiable({ 'user_id': 123, @@ -161,31 +227,6 @@ 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 2a881d2145..f00bf4428f 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -829,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/settings_test.dart b/test/api/route/settings_test.dart new file mode 100644 index 0000000000..8b31caa646 --- /dev/null +++ b/test/api/route/settings_test.dart @@ -0,0 +1,53 @@ +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/settings.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + test('smoke updateSettings', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + + final newSettings = {}; + final expectedBodyFields = {}; + for (final name in UserSettingName.values) { + switch (name) { + case UserSettingName.twentyFourHourTime: + newSettings[name] = TwentyFourHourTimeMode.twelveHour; + expectedBodyFields['twenty_four_hour_time'] = 'false'; + case UserSettingName.displayEmojiReactionUsers: + newSettings[name] = false; + expectedBodyFields['display_emoji_reaction_users'] = 'false'; + case UserSettingName.emojiset: + newSettings[name] = Emojiset.googleBlob; + expectedBodyFields['emojiset'] = 'google-blob'; + case UserSettingName.presenceEnabled: + newSettings[name] = true; + expectedBodyFields['presence_enabled'] = 'true'; + } + } + + await updateSettings(connection, newSettings: newSettings); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/settings') + ..bodyFields.deepEquals(expectedBodyFields); + }); + }); + + test('TwentyFourHourTime.localeDefault', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + + // TODO(server-future) instead, check for twenty_four_hour_time: null + // (could be an error-prone part of the JSONification) + check(() => updateSettings(connection, + newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault}) + ).throws(); + }); + }); +} 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/basic_test.dart b/test/basic_test.dart new file mode 100644 index 0000000000..7389ccca75 --- /dev/null +++ b/test/basic_test.dart @@ -0,0 +1,48 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/basic.dart'; + +void main() { + group('Option', () { + test('==/hashCode', () { + void checkEqual(Object a, Object b) { + check(a).equals(b); + check(a.hashCode).equals(b.hashCode); + } + + void checkUnequal(Object a, Object b) { + check(a).not((it) => it.equals(b)); + } + + checkEqual(OptionNone(), OptionNone()); + checkEqual(OptionNone(), OptionNone()); + + checkEqual(OptionSome(3), OptionSome(3)); + checkEqual(OptionSome(null), OptionSome(null)); + checkEqual(OptionSome(OptionSome(3)), OptionSome(OptionSome(3))); + + checkUnequal(OptionNone(), OptionSome(null)); + checkUnequal(OptionSome(3), OptionSome(OptionSome(3))); + checkUnequal(3, OptionSome(3)); + }); + + test('or', () { + check(OptionSome(3).or(4)).equals(3); + check(OptionSome(3).or(4)).equals(3); + check(OptionSome(null).or(4)).equals(null); + check(OptionNone().or(4)).equals(4); + }); + + test('orElse', () { + check(OptionSome(3).orElse(() => 4)).equals(3); + check(OptionSome(3).orElse(() => 4)).equals(3); + check(OptionSome(null).orElse(() => 4)).equals(null); + check(OptionNone().orElse(() => 4)).equals(4); + + final myError = Error(); + check(OptionSome(3).orElse(() => throw myError)).equals(3); + check(() => OptionNone().orElse(() => throw myError)) + .throws().identicalTo(myError); + }); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index d803196269..b44108eb21 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -12,10 +12,12 @@ 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'; +import 'model/binding.dart'; import 'model/test_store.dart'; import 'stdlib_checks.dart'; @@ -23,7 +25,7 @@ void _checkPositive(int? value, String description) { assert(value == null || value > 0, '$description should be positive'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Error objects. // @@ -69,7 +71,7 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Time values. // @@ -83,7 +85,7 @@ int utcTimestamp([DateTime? dateTime]) { return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // @@ -129,7 +131,32 @@ GetServerSettingsResult serverSettings({ ); } -ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { +CustomProfileField customProfileField( + int id, + CustomProfileFieldType type, { + int? order, + bool? displayInProfileSummary, + String? fieldData, +}) { + return CustomProfileField( + id: id, + type: type, + order: order ?? id, + name: 'field$id', + hint: 'hint$id', + fieldData: fieldData ?? '', + displayInProfileSummary: displayInProfileSummary ?? false, + ); +} + +ServerEmojiData _immutableServerEmojiData({ + required Map> codeToNames}) { + return ServerEmojiData( + codeToNames: Map.unmodifiable(codeToNames.map( + (k, v) => MapEntry(k, List.unmodifiable(v))))); +} + +final ServerEmojiData serverEmojiDataPopular = _immutableServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['slight_smile'], @@ -156,7 +183,7 @@ ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { /// /// zulip/zulip@9feba0f16f is a Server 11 commit. // TODO(server-11) can drop this -ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { +final ServerEmojiData serverEmojiDataPopularLegacy = _immutableServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['smile'], @@ -165,6 +192,26 @@ ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { '1f419': ['octopus'], }); +/// A fresh user-group ID, from a random but always strictly increasing sequence. +int _nextUserGroupId() => (_lastUserGroupId += 1 + Random().nextInt(10)); +int _lastUserGroupId = 100; + +UserGroup userGroup({ + int? id, + String? name, + String? description, + bool isSystemGroup = false, + bool deactivated = false, +}) { + return UserGroup( + id: id ??= _nextUserGroupId(), + name: name ??= 'group-$id', + description: description ?? 'A group named $name', + isSystemGroup: isSystemGroup, + deactivated: deactivated, + ); +} + RealmEmojiItem realmEmojiItem({ required String emojiCode, required String emojiName, @@ -184,7 +231,7 @@ RealmEmojiItem realmEmojiItem({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Users and accounts. // @@ -268,25 +315,78 @@ Account account({ ); } -final User selfUser = user(fullName: 'Self User'); +/// A [User] which throws on attempting to mutate any of its fields. +/// +/// We use this to prevent any tests from leaking state through having a +/// [PerAccountStore] (which will be discarded when [TestZulipBinding.reset] +/// is called at the end of the test case) mutate a [User] in its [UserStore] +/// which happens to a value in this file like [selfUser] (which will not be +/// discarded by [TestZulipBinding.reset]). That was the cause of issue #1712. +class _ImmutableUser extends User { + _ImmutableUser.copyUser(User user) : super( + // When adding a field here, be sure to add the corresponding setter below. + userId: user.userId, + deliveryEmail: user.deliveryEmail, + email: user.email, + fullName: user.fullName, + dateJoined: user.dateJoined, + isActive: user.isActive, + isBillingAdmin: user.isBillingAdmin, + isBot: user.isBot, + botType: user.botType, + botOwnerId: user.botOwnerId, + role: user.role, + timezone: user.timezone, + avatarUrl: user.avatarUrl, + avatarVersion: user.avatarVersion, + profileData: user.profileData == null ? null : Map.unmodifiable(user.profileData!), + isSystemBot: user.isSystemBot, + // When adding a field here, be sure to add the corresponding setter below. + ); + + static final Error _error = UnsupportedError( + 'Cannot mutate immutable User.\n' + 'When a test needs to have the store handle an event which will\n' + 'modify a user, use `eg.user()` to make a fresh User object\n' + 'instead of using a shared User object like `eg.selfUser`.'); + + // userId already immutable + @override set deliveryEmail(_) => throw _error; + @override set email(_) => throw _error; + @override set fullName(_) => throw _error; + // dateJoined already immutable + @override set isActive(_) => throw _error; + @override set isBillingAdmin(_) => throw _error; + // isBot already immutable + // botType already immutable + @override set botOwnerId(_) => throw _error; + @override set role(_) => throw _error; + @override set timezone(_) => throw _error; + @override set avatarUrl(_) => throw _error; + @override set avatarVersion(_) => throw _error; + @override set profileData(_) => throw _error; + // isSystemBot already immutable +} + +final User selfUser = _ImmutableUser.copyUser(user(fullName: 'Self User')); +final User otherUser = _ImmutableUser.copyUser(user(fullName: 'Other User')); +final User thirdUser = _ImmutableUser.copyUser(user(fullName: 'Third User')); +final User fourthUser = _ImmutableUser.copyUser(user(fullName: 'Fourth User')); + +// There's no need for an [Account] analogue of [_ImmutableUser], +// because [Account] (which is generated by Drift) is already immutable. final Account selfAccount = account( id: 1001, user: selfUser, apiKey: 'dQcEJWTq3LczosDkJnRTwf31zniGvMrO', // A Zulip API key is 32 digits of base64. ); - -final User otherUser = user(fullName: 'Other User'); final Account otherAccount = account( id: 1002, user: otherUser, apiKey: '6dxT4b73BYpCTU+i4BB9LAKC5h/CufqY', // A Zulip API key is 32 digits of base64. ); -final User thirdUser = user(fullName: 'Third User'); - -final User fourthUser = user(fullName: 'Fourth User'); - -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Data attached to the self-account on the realm // @@ -308,7 +408,7 @@ SavedSnippet savedSnippet({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -423,25 +523,25 @@ UserTopicItem userTopicItem( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Messages, and pieces of messages. // -Reaction unicodeEmojiReaction = Reaction( +final Reaction unicodeEmojiReaction = Reaction( emojiName: 'thumbs_up', emojiCode: '1f44d', reactionType: ReactionType.unicodeEmoji, userId: selfUser.userId, ); -Reaction realmEmojiReaction = Reaction( +final Reaction realmEmojiReaction = Reaction( emojiName: 'twocents', emojiCode: '181', reactionType: ReactionType.realmEmoji, userId: selfUser.userId, ); -Reaction zulipExtraEmojiReaction = Reaction( +final Reaction zulipExtraEmojiReaction = Reaction( emojiName: 'zulip', emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji, @@ -521,6 +621,8 @@ StreamMessage streamMessage({ List? reactions, int? timestamp, List? flags, + String? matchContent, + String? matchTopic, List? submessages, }) { _checkPositive(id, 'message ID'); @@ -544,6 +646,8 @@ StreamMessage streamMessage({ 'submessages': submessages ?? [], 'timestamp': timestamp ?? 1678139636, 'type': 'stream', + 'match_content': matchContent, + 'match_subject': matchTopic, }) as Map); } @@ -588,20 +692,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, ); @@ -625,6 +790,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, @@ -645,7 +867,7 @@ Submessage submessage({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Aggregate data structures. // @@ -680,7 +902,7 @@ UnreadMessagesSnapshot unreadMsgs({ } const _unreadMsgs = unreadMsgs; -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Events. // @@ -695,8 +917,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); @@ -948,7 +1175,7 @@ ChannelUpdateEvent channelUpdateEvent( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // The entire per-account or global state. // @@ -976,15 +1203,21 @@ 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? realmUserGroups, List? recentPrivateConversations, List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, + Map? userStatuses, UserSettings? userSettings, List? userTopics, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, @@ -992,6 +1225,7 @@ InitialSnapshot initialSnapshot({ int? realmWaitingPeriodThreshold, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, @@ -1009,22 +1243,29 @@ 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 ?? {}, + realmUserGroups: realmUserGroups ?? [], recentPrivateConversations: recentPrivateConversations ?? [], savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default + userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( - twentyFourHourTime: false, + twentyFourHourTime: TwentyFourHourTimeMode.twelveHour, displayEmojiReactionUsers: true, emojiset: Emojiset.google, + presenceEnabled: true, ), userTopics: userTopics, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, @@ -1032,6 +1273,7 @@ InitialSnapshot initialSnapshot({ realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, 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 0bfbd2d33a..562e34db2f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From the Flutter engine, i.e. from dart:ui. // @@ -40,7 +40,7 @@ extension FontVariationChecks on Subject { Subject get value => has((x) => x.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/foundation.dart'. // @@ -48,7 +48,7 @@ extension ValueListenableChecks on Subject> { Subject get value => has((c) => c.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/services.dart'. // @@ -62,7 +62,7 @@ extension TextEditingValueChecks on Subject { Subject get composing => has((x) => x.composing, 'composing'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/animation.dart'. // @@ -71,7 +71,7 @@ extension AnimationChecks on Subject> { Subject get value => has((d) => d.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/painting.dart'. // @@ -97,7 +97,7 @@ extension InlineSpanChecks on Subject { Subject get style => has((x) => x.style, 'style'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/rendering.dart'. // @@ -110,7 +110,7 @@ extension RenderParagraphChecks on Subject { Subject get didExceedMaxLines => has((x) => x.didExceedMaxLines, 'didExceedMaxLines'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/widgets.dart'. // @@ -192,7 +192,7 @@ extension PageRouteChecks on Subject> { Subject get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/material.dart'. // @@ -249,7 +249,3 @@ extension IconButtonChecks on Subject { extension SwitchListTileChecks on Subject { Subject get value => has((x) => x.value, 'value'); } - -extension RadioListTileChecks on Subject> { - 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 cab073db48..b95bb58ccb 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -404,19 +404,33 @@ void main() { }); group('MentionAutocompleteQuery.testUser', () { + late PerAccountStore store; + void doCheck(String rawQuery, User user, bool expected) { final result = MentionAutocompleteQuery(rawQuery) - .testUser(user, AutocompleteDataCache()); + .testUser(user, AutocompleteDataCache(), store); expected ? check(result).isTrue() : check(result).isFalse(); } test('user is always excluded when not active regardless of other criteria', () { + store = eg.store(); + doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false); // When active then other criteria will be checked doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true); }); + test('user is always excluded when muted, regardless of other criteria', () async { + store = eg.store(); + await store.setMutedUsers([1]); + doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false); + // When not muted, then other criteria will be checked + doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true); + }); + test('user is included if fullname words match the query', () { + store = eg.store(); + doCheck('', eg.user(fullName: 'Full Name'), true); doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash doCheck('Full Name', eg.user(fullName: 'Full Name'), true); diff --git a/test/model/binding.dart b/test/model/binding.dart index 2c70b68826..ca9f43603f 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'; @@ -311,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. /// @@ -417,7 +422,7 @@ class TestZulipBinding extends ZulipBinding { } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { - //////////////////////////////// + //|////////////////////////////// // Permissions. NotificationSettings requestPermissionResult = const NotificationSettings( @@ -466,7 +471,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { return requestPermissionResult; } - //////////////////////////////// + //|////////////////////////////// // Tokens. String? _initialToken; @@ -522,7 +527,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { } } - //////////////////////////////// + //|////////////////////////////// // Messages. StreamController onMessage = StreamController.broadcast(); @@ -756,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/channel_test.dart b/test/model/channel_test.dart index 4f06cc2fcc..07e542e6a1 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -1,4 +1,3 @@ - import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; @@ -7,7 +6,9 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/channel.dart'; +import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; import 'test_store.dart'; void main() { @@ -146,7 +147,7 @@ void main() { test('with nothing for topic', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); @@ -158,9 +159,13 @@ void main() { UserTopicVisibilityPolicy.unmuted, UserTopicVisibilityPolicy.followed, ]) { - await store.addUserTopic(stream1, 'topic', policy); + await store.setUserTopic(stream1, 'topic', policy); check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(policy); + + // Case-insensitive + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('ToPiC'))) + .equals(policy); } }); }); @@ -193,27 +198,39 @@ void main() { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isFalse(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('ToPiC'))).isFalse(); + check(store.isTopicVisible (stream1.streamId, eg.t('ToPiC'))).isFalse(); }); test('with policy unmuted', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('tOpIc'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('tOpIc'))).isTrue(); }); test('with policy followed', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('TOPIC'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('TOPIC'))).isTrue(); }); }); @@ -221,12 +238,29 @@ void main() { UserTopicEvent mkEvent(UserTopicVisibilityPolicy policy) => eg.userTopicEvent(stream1.streamId, 'topic', policy); + // For testing case-insensitivity + UserTopicEvent mkEventDifferentlyCased(UserTopicVisibilityPolicy policy) => + eg.userTopicEvent(stream1.streamId, 'ToPiC', policy); + + assert(() { + // (sanity check on mkEvent and mkEventDifferentlyCased) + final event1 = mkEvent(UserTopicVisibilityPolicy.followed); + final event2 = mkEventDifferentlyCased(UserTopicVisibilityPolicy.followed); + return event1.topicName.isSameAs(event2.topicName) + && event1.topicName.apiName != event2.topicName.apiName; + }()); + void checkChanges(PerAccountStore store, UserTopicVisibilityPolicy newPolicy, - VisibilityEffect expectedInStream, VisibilityEffect expectedOverall) { + UserTopicVisibilityEffect expectedInStream, + UserTopicVisibilityEffect expectedOverall) { final event = mkEvent(newPolicy); check(store.willChangeIfTopicVisibleInStream(event)).equals(expectedInStream); check(store.willChangeIfTopicVisible (event)).equals(expectedOverall); + + final event2 = mkEventDifferentlyCased(newPolicy); + check(store.willChangeIfTopicVisibleInStream(event2)).equals(expectedInStream); + check(store.willChangeIfTopicVisible (event2)).equals(expectedOverall); } test('stream not muted, policy none -> followed, no change', () async { @@ -234,7 +268,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.none); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.none); }); test('stream not muted, policy none -> muted, means muted', () async { @@ -242,7 +276,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.muted); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.muted); }); test('stream muted, policy none -> followed, means none/unmuted', () async { @@ -250,7 +284,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.unmuted); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.unmuted); }); test('stream muted, policy none -> muted, means muted/none', () async { @@ -258,7 +292,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.none); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.none); }); final policies = [ @@ -293,10 +327,10 @@ void main() { final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); final newVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); - VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { - if (newVisible == oldVisible) return VisibilityEffect.none; - if (newVisible) return VisibilityEffect.unmuted; - return VisibilityEffect.muted; + UserTopicVisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { + if (newVisible == oldVisible) return UserTopicVisibilityEffect.none; + if (newVisible) return UserTopicVisibilityEffect.unmuted; + return UserTopicVisibilityEffect.muted; } check(willChangeInStream) .equals(fromOldNew(oldVisibleInStream, newVisibleInStream)); @@ -340,7 +374,7 @@ void main() { group('events', () { test('add with new stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.muted), ]); @@ -348,8 +382,8 @@ void main() { test('add in existing stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.muted), eg.userTopicItem(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted), @@ -358,18 +392,24 @@ void main() { test('update existing policy', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.unmuted), ]); + + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.followed); + compareTopicVisibility(store, [ + eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.followed), + ]); }); test('remove, with others in stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted), ]); @@ -377,16 +417,18 @@ void main() { test('remove, as last in stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.none); compareTopicVisibility(store, [ ]); }); test('treat unknown enum value as removing', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unknown); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.unknown); compareTopicVisibility(store, [ ]); }); @@ -403,7 +445,8 @@ void main() { ])); check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 1'))) .equals(UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 2'))) + // case-insensitivity + check(store.topicVisibilityPolicy(stream.streamId, eg.t('ToPiC 2'))) .equals(UserTopicVisibilityPolicy.unmuted); check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 3'))) .equals(UserTopicVisibilityPolicy.followed); @@ -411,4 +454,106 @@ void main() { .equals(UserTopicVisibilityPolicy.none); }); }); + + group('hasPostingPermission', () { + final testCases = [ + (ChannelPostPolicy.unknown, UserRole.unknown, true), + (ChannelPostPolicy.unknown, UserRole.guest, true), + (ChannelPostPolicy.unknown, UserRole.member, true), + (ChannelPostPolicy.unknown, UserRole.moderator, true), + (ChannelPostPolicy.unknown, UserRole.administrator, true), + (ChannelPostPolicy.unknown, UserRole.owner, true), + (ChannelPostPolicy.any, UserRole.unknown, true), + (ChannelPostPolicy.any, UserRole.guest, true), + (ChannelPostPolicy.any, UserRole.member, true), + (ChannelPostPolicy.any, UserRole.moderator, true), + (ChannelPostPolicy.any, UserRole.administrator, true), + (ChannelPostPolicy.any, UserRole.owner, true), + (ChannelPostPolicy.fullMembers, UserRole.unknown, true), + (ChannelPostPolicy.fullMembers, UserRole.guest, false), + // The fullMembers/member case gets its own tests further below. + // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), + (ChannelPostPolicy.fullMembers, UserRole.moderator, true), + (ChannelPostPolicy.fullMembers, UserRole.administrator, true), + (ChannelPostPolicy.fullMembers, UserRole.owner, true), + (ChannelPostPolicy.moderators, UserRole.unknown, true), + (ChannelPostPolicy.moderators, UserRole.guest, false), + (ChannelPostPolicy.moderators, UserRole.member, false), + (ChannelPostPolicy.moderators, UserRole.moderator, true), + (ChannelPostPolicy.moderators, UserRole.administrator, true), + (ChannelPostPolicy.moderators, UserRole.owner, true), + (ChannelPostPolicy.administrators, UserRole.unknown, true), + (ChannelPostPolicy.administrators, UserRole.guest, false), + (ChannelPostPolicy.administrators, UserRole.member, false), + (ChannelPostPolicy.administrators, UserRole.moderator, false), + (ChannelPostPolicy.administrators, UserRole.administrator, true), + (ChannelPostPolicy.administrators, UserRole.owner, true), + ]; + + for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { + test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' + 'with "${policy.name}" policy', () { + final store = eg.store(); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), + // [byDate] is not actually relevant for these test cases; for the + // ones which it is, they're practiced below. + byDate: DateTime.now()); + check(actual).equals(canPost); + }); + } + + group('"member" user posting in a channel with "fullMembers" policy', () { + PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => + eg.store(initialSnapshot: eg.initialSnapshot( + realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + + User memberUser({required String dateJoined}) => eg.user( + role: UserRole.member, dateJoined: dateJoined); + + test('a "full" member -> can post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final hasPermission = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 10, 00)); + check(hasPermission).isTrue(); + }); + + test('not a "full" member -> cannot post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 09, 59)); + check(actual).isFalse(); + }); + }); + }); + + group('makeTopicKeyedMap', () { + test('"a" equals "A"', () { + final map = makeTopicKeyedMap() + ..[eg.t('a')] = 1 + ..[eg.t('A')] = 2; + check(map) + ..[eg.t('a')].equals(2) + ..[eg.t('A')].equals(2) + ..entries.which((it) => it.single + ..key.apiName.equals('a') + ..value.equals(2)); + }); + + test('"A" equals "a"', () { + final map = makeTopicKeyedMap() + ..[eg.t('A')] = 1 + ..[eg.t('a')] = 2; + check(map) + ..[eg.t('A')].equals(2) + ..[eg.t('a')].equals(2) + ..entries.which((it) => it.single + ..key.apiName.equals('A') + ..value.equals(2)); + }); + }); } 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..5eaf5500fa 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -512,44 +512,42 @@ class ContentExample { static final mathInline = ContentExample.inline( 'inline math', r"$$ \lambda $$", - expectedText: r'\lambda', + expectedText: r'λ', '

' 'λ' ' \\lambda ' '

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

' 'λ' '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ])]); - 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,30 +561,28 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ]); - 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,19 +598,18 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ]), ])]); - 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,30 +626,28 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ])]); - 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,13 +673,13 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(),text: null, nodes: []), - KatexNode( + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), + text: 'a'), ]), ]), ImageNodeList([ @@ -699,200 +692,6 @@ 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( - '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```', - '

' - '' - '1234567890' - '\\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' - '

', - [ - 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( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: []), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', - nodes: null), - ]), - ]), - ]); - - static final mathBlockKatexNestedSizing = ContentExample( - 'math block; KaTeX nested sizing', - '```math\n\\tiny {1 \\Huge 2}\n```', - '

' - '' - '12' - '\\tiny {1 \\Huge 2}' - '

', - [ - MathBlockNode( - texSource: '\\tiny {1 \\Huge 2}', - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: []), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexNode( - styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', - nodes: null), - ]), - ]), - ]), - ]); - - static final 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```', - '

' - '' - '([' - '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊' - '

', - [ - MathBlockNode( - texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: []), - KatexNode( - styles: KatexSpanStyles(), - text: '⟨', - nodes: null), - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', - nodes: null), - ]), - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', - nodes: null), - ]), - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', - nodes: null), - ]), - KatexNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', - nodes: null), - ]), - ]), - ]), - ]); - static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1327,6 +1126,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 +1459,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 { @@ -1953,14 +1773,14 @@ void main() async { testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + // The math examples in this file are about how math blocks and spans fit + // into the context of a Zulip message. + // For tests going deeper inside KaTeX content, see katex_test.dart. testParseExample(ContentExample.mathBlock); testParseExample(ContentExample.mathBlocksMultipleInParagraph); testParseExample(ContentExample.mathBlockInQuote); testParseExample(ContentExample.mathBlocksMultipleInQuote); testParseExample(ContentExample.mathBlockBetweenImages); - testParseExample(ContentExample.mathBlockKatexSizing); - testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); @@ -1984,6 +1804,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 +1857,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..ecf9e03bb1 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:drift_dev/api/migrations_common.dart' show ValidationOptions; import 'package:drift_dev/api/migrations_native.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/model/database.dart'; @@ -200,8 +201,8 @@ void main() { final before = AppDatabase(schema.newConnection()); await before.customStatement('CREATE TABLE test_extra (num int)'); await before.customStatement('ALTER TABLE accounts ADD extra_column int'); - await check(verifier.migrateAndValidate( - before, toVersion, validateDropped: true)).throws(); + await check(verifier.migrateAndValidate(before, toVersion, + options: const ValidationOptions(validateDropped: true))).throws(); // Override the schema version by modifying the underlying value // drift internally keeps track of in the database. // TODO(drift): Expose a better interface for testing this. @@ -211,7 +212,8 @@ void main() { // Simulate starting up the app, with an older schema version that // does not have the extra tables and columns. final after = AppDatabase(schema.newConnection()); - await verifier.migrateAndValidate(after, toVersion, validateDropped: true); + await verifier.migrateAndValidate(after, toVersion, + options: const ValidationOptions(validateDropped: true)); // Check that a custom migration/setup step of ours got run too. check(await after.getGlobalSettings()).themeSetting.isNull(); await after.close(); @@ -326,6 +328,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/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/katex_test.dart b/test/model/katex_test.dart new file mode 100644 index 0000000000..3b0916d3c0 --- /dev/null +++ b/test/model/katex_test.dart @@ -0,0 +1,611 @@ +import 'dart:io'; + +import 'package:zulip/model/settings.dart'; +import 'package:checks/checks.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/katex.dart'; + +import 'binding.dart'; +import 'content_test.dart'; + +/// An example of KaTeX Zulip content for test cases. +/// +/// For guidance on writing examples, see comments on [ContentExample]. +class KatexExample extends ContentExample { + KatexExample.inline(String description, String texSource, String html, + List? expectedNodes) + : super.inline(description, '\$\$ $texSource \$\$', html, + MathInlineNode(texSource: texSource, nodes: expectedNodes)); + + KatexExample.block(String description, String texSource, String html, + List? expectedNodes) + : super(description, '```math\n$texSource\n```', html, + [MathBlockNode(texSource: texSource, nodes: expectedNodes)]); + + // 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 = KatexExample.block( + 'math block; KaTeX different sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 + '\\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', + '

' + '' + '1234567890' + '\\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' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0'), + ]), + ]); + + static final mathBlockKatexNestedSizing = KatexExample.block( + 'math block; KaTeX nested sizing', + r'\tiny {1 \Huge 2}', + '

' + '' + '12' + '\\tiny {1 \\Huge 2}' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + nodes: [ + KatexSpanNode(text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2'), + ]), + ]), + ]); + + static final mathBlockKatexDelimSizing = KatexExample.block( + 'math block; KaTeX delimiter sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 + r'⟨ \big( \Big[ \bigg⌈ \Bigg⌊', + '

' + '' + '([' + '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), + KatexSpanNode(text: '⟨'), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '('), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '['), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈'), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊'), + ]), + ]), + ]); + + static final mathBlockKatexSpace = KatexExample.block( + 'math block; KaTeX space', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 + '1:2', + '

' + '' + '1:21:2' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + nodes: []), + KatexSpanNode(text: ':'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + nodes: []), + ]), + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '2'), + ]), + ]); + + static final mathBlockKatexSuperscript = KatexExample.block( + 'math block, KaTeX superscript; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 + "a'", + '

' + '' + 'a' + 'a'' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode(text: '′'), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexSubscript = KatexExample.block( + 'math block, KaTeX subscript; two vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 + 'x_n', + '

' + '' + 'xn' + 'x_n' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'x'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n'), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexSubSuperScript = KatexExample.block( + 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 + '_u^o', + '

' + '' + 'uo' + '_u^o' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), + KatexSpanNode(nodes: [ + KatexSpanNode(nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'u'), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'o'), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexRaisebox = KatexExample.block( + 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 + r'a\raisebox{0.25em}{$b$}c', + '

' + '' + 'abc' + 'a\\raisebox{0.25em}{\$b\$}c' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b'), + ]), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'c'), + ]), + ]); + + static final mathBlockKatexNegativeMargin = KatexExample.block( + 'math block, KaTeX negative margin', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 + r'1 \! 2', + '

' + '' + '1 ⁣21 \\! 2' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '1'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(text: '2'), + ]), + ]), + ]); + + static final mathBlockKatexLogo = KatexExample.block( + 'math block, KaTeX logo', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 + r'\KaTeX', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A'), + ]), + ])), + ]), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E'), + ]), + ])), + ]), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X'), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexNegativeMarginsOnVlistRow = KatexExample.block( + 'math block, KaTeX negative margins on a vlist row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 + 'X_n', + '

' + '' + 'XnX_n' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n'), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]); +} + +void main() async { + TestZulipBinding.ensureInitialized(); + + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + + testParseExample(KatexExample.mathBlockKatexSizing); + testParseExample(KatexExample.mathBlockKatexNestedSizing); + testParseExample(KatexExample.mathBlockKatexDelimSizing); + testParseExample(KatexExample.mathBlockKatexSpace); + testParseExample(KatexExample.mathBlockKatexSuperscript); + testParseExample(KatexExample.mathBlockKatexSubscript); + testParseExample(KatexExample.mathBlockKatexSubSuperScript); + testParseExample(KatexExample.mathBlockKatexRaisebox); + testParseExample(KatexExample.mathBlockKatexNegativeMargin); + testParseExample(KatexExample.mathBlockKatexLogo); + testParseExample(KatexExample.mathBlockKatexNegativeMarginsOnVlistRow); + + test('all KaTeX content examples are tested', () { + // Check that every KatexExample defined above has a corresponding + // actual test case that runs on it. If you've added a new example + // and this test breaks, remember to add a `testParseExample` call for it. + + // This implementation is a bit of a hack; it'd be cleaner to get the + // actual Dart parse tree using package:analyzer. Unfortunately that + // approach takes several seconds just to load the parser library, enough + // to add noticeably to the runtime of our whole test suite. + final thisFilename = Trace.current().frames[0].uri.path; + final source = File(thisFilename).readAsStringSync(); + final declaredExamples = RegExp(multiLine: true, + r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*(?:inline|block)\s*)?\(', + ).allMatches(source).map((m) => m.group(1)); + final testedExamples = RegExp(multiLine: true, + r'^\s*testParseExample\s*\(\s*KatexExample\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 + // any one platform is enough; avoid dealing with Windows file paths + ); +} diff --git a/test/model/message_checks.dart b/test/model/message_checks.dart new file mode 100644 index 0000000000..b56cd89a79 --- /dev/null +++ b/test/model/message_checks.dart @@ -0,0 +1,9 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; + +extension OutboxMessageChecks on Subject> { + Subject get localMessageId => has((x) => x.localMessageId, 'localMessageId'); + Subject get state => has((x) => x.state, 'state'); + Subject get hidden => has((x) => x.hidden, 'hidden'); +} diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index ac41d771ec..dbe2e6d8eb 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,8 +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'; @@ -10,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'; @@ -21,12 +26,16 @@ 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 @@ -47,6 +56,8 @@ void main() { 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; @@ -66,15 +77,25 @@ 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, + List? users, + List? mutedUserIds, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); await store.addSubscription(subscription); + await store.addUsers([...?users, eg.selfUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } 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++; @@ -87,15 +108,42 @@ 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, @@ -150,12 +198,13 @@ 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, ); } @@ -180,7 +229,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 { @@ -194,9 +258,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.setUserTopic(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(); @@ -243,8 +427,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, @@ -256,12 +440,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(), @@ -273,42 +457,102 @@ void main() { ); }); - 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 @@ -316,45 +560,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))); @@ -366,14 +638,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); @@ -390,8 +678,31 @@ 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]); + }); }); + // TODO(#1569): test jumpToEnd + group('MessageEvent', () { test('in narrow', () async { final stream = eg.stream(); @@ -418,6 +729,19 @@ void main() { 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)); @@ -425,6 +749,199 @@ void main() { 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(); + })); + }); + + group('addOutboxMessage', () { + final stream = eg.stream(); + + 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(); + })); + }); + + group('removeOutboxMessage', () { + final stream = eg.stream(); + + 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', () { @@ -448,7 +965,7 @@ void main() { await setVisibility(policy); } - 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(); @@ -462,10 +979,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. @@ -540,7 +1096,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: [ @@ -548,10 +1104,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()); @@ -561,7 +1124,14 @@ void main() { eg.streamMessage(id: 2, stream: stream, topic: topic), ]; await prepareMessages(foundOldest: true, messages: messages); + await store.setUserTopic(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()); @@ -569,10 +1139,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 { @@ -596,6 +1170,144 @@ void main() { })); }); + group('MutedUsersEvent', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + final users = [user1, user2, user3]; + + test('CombinedFeedNarrow', () async { + await prepare(narrow: CombinedFeedNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user1, user2]), + eg.dmMessage(id: 3, from: eg.selfUser, to: [user2, user3]), + eg.dmMessage(id: 4, from: eg.selfUser, to: []), + eg.streamMessage(id: 5), + ]); + checkHasMessageIds([1, 2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId, user2.userId]); + checkNotifiedOnce(); + checkHasMessageIds([3, 4, 5]); + }); + + test('MentionsNarrow', () async { + await prepare(narrow: MentionsNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.mentioned]), + eg.streamMessage(id: 3, flags: [MessageFlag.mentioned]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('StarredMessagesNarrow', () async { + await prepare(narrow: StarredMessagesNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.starred]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.starred]), + eg.streamMessage(id: 3, flags: [MessageFlag.starred]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('ChannelNarrow -> do nothing', () async { + await prepare(narrow: ChannelNarrow(eg.defaultStreamMessageStreamId), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('TopicNarrow -> do nothing', () async { + await prepare(narrow: TopicNarrow(eg.defaultStreamMessageStreamId, + TopicName('topic')), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1, topic: 'topic'), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('DmNarrow -> do nothing', () async { + await prepare( + narrow: DmNarrow.withUser(user1.userId, selfUserId: eg.selfUser.userId), + users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('unmute a user -> refetch from scratch', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + checkHasMessageIds([2]); + + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + await store.setMutedUsers([]); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + checkHasMessageIds([]); + + async.elapse(Duration.zero); + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + + test('unmute a user before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + final fetchFuture = model.fetchInitial(); + await store.setMutedUsers([]); + checkNotNotified(); + + await fetchFuture; + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + }); + group('DeleteMessageEvent', () { final stream = eg.stream(); final messages = List.generate(30, (i) => eg.streamMessage(stream: stream)); @@ -718,6 +1430,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()); @@ -847,6 +1591,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); @@ -1002,7 +1766,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)); }); @@ -1068,7 +1838,7 @@ void main() { messages: olderMessages, ).toJson()); final fetchFuture = model.fetchOlder(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -1081,7 +1851,7 @@ void main() { origStreamId: otherStream.streamId, newMessages: movedMessages, )); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkHasMessages([]); checkNotifiedOnce(); @@ -1104,7 +1874,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( @@ -1117,7 +1887,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1138,7 +1908,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); @@ -1156,36 +1926,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(); @@ -1267,7 +2037,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( @@ -1280,7 +2050,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1293,19 +2063,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(); })); }); @@ -1321,12 +2091,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]) { @@ -1366,7 +2138,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: @@ -1445,9 +2218,9 @@ void main() { await prepare(narrow: const CombinedFeedNarrow()); await store.addStreams([stream1, stream2]); await store.addSubscription(eg.subscription(stream1)); - await store.addUserTopic(stream1, 'B', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'B', UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream2, isMuted: true)); - await store.addUserTopic(stream2, 'C', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream2, 'C', UserTopicVisibilityPolicy.unmuted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ @@ -1505,8 +2278,8 @@ void main() { await prepare(narrow: ChannelNarrow(stream.streamId)); await store.addStream(stream); await store.addSubscription(eg.subscription(stream, isMuted: true)); - await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.unmuted); - await store.addUserTopic(stream, 'C', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'A', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'C', UserTopicVisibilityPolicy.muted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ @@ -1545,12 +2318,45 @@ void main() { 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.setUserTopic(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')); await store.addStream(stream); await store.addSubscription(eg.subscription(stream, isMuted: true)); - await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ @@ -1580,7 +2386,7 @@ void main() { const mutedTopic = 'muted'; await prepare(narrow: const MentionsNarrow()); await store.addStream(stream); - await store.addUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream, isMuted: true)); List getMessages(int startingId) => [ @@ -1618,7 +2424,7 @@ void main() { const mutedTopic = 'muted'; await prepare(narrow: const StarredMessagesNarrow()); await store.addStream(stream); - await store.addUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream, isMuted: true)); List getMessages(int startingId) => [ @@ -1674,8 +2480,9 @@ void main() { ..middleMessage.equals(0); }); - test('on fetchInitial not empty', () async { - await prepare(narrow: const CombinedFeedNarrow()); + 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]); @@ -1698,6 +2505,34 @@ void main() { .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, @@ -1894,7 +2729,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. @@ -1907,7 +2790,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); @@ -1915,7 +2798,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)], @@ -1966,6 +2849,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, @@ -1978,17 +2875,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, @@ -1996,6 +2956,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: [ @@ -2005,6 +2967,13 @@ 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: @@ -2017,8 +2986,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', () { @@ -2089,6 +3060,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', () { @@ -2124,6 +3105,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); } } } @@ -2139,39 +3128,68 @@ 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 (message is! StreamMessage) continue; - switch (model.narrow) { - case CombinedFeedNarrow(): - check(model.store.isTopicVisible(message.streamId, message.topic)) - .isTrue(); - case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(message.streamId, message.topic)) - .isTrue(); - case TopicNarrow(): - case DmNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): + } + 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]; + + for (final message in allMessages) { + check(model.narrow.containsMessage(message)).anyOf(>[ + (it) => it.isNull(), + (it) => it.isNotNull().isTrue(), + ]); + + if (message is MessageBase) { + final conversation = message.conversation; + switch (model.narrow) { + case CombinedFeedNarrow(): + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) + .isTrue(); + case ChannelNarrow(): + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) + .isTrue(); + case TopicNarrow(): + case DmNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + } + } else if (message is DmMessage) { + final narrow = DmNarrow.ofMessage(message, selfUserId: model.store.selfUserId); + switch (model.narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + check(model.store.shouldMuteDmConversation(narrow)).isFalse(); + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + } } } 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) @@ -2204,26 +3222,34 @@ void checkInvariants(MessageListView model) { } int i = 0; - for (int j = 0; j < model.messages.length; j++) { + for (int j = 0; j < allMessages.length; j++) { bool forcedShowSender = false; if (j == 0 - || !haveSameRecipient(model.messages[j-1], model.messages[j])) { + || 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() => true, }); @@ -2233,8 +3259,14 @@ void checkInvariants(MessageListView model) { check(model).middleItem ..isGreaterOrEqual(0) ..isLessOrEqual(model.items.length); - if (model.middleItem == model.items.length) { - check(model.middleMessage).equals(model.messages.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]); @@ -2275,12 +3307,13 @@ 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 1809f0888b..69f349c02d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,14 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; import 'package:crypto/crypto.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -18,12 +21,17 @@ import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../fake_async_checks.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; +import 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + // These "late" variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -42,20 +50,31 @@ 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]. @@ -76,6 +95,406 @@ 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', () { + test('smoke', () async { + 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()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('world')), + content: 'hello'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': stream.streamId.toString(), + 'topic': 'world', + 'content': 'hello', + 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), + }); + }); + + 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(); @@ -122,6 +541,37 @@ void main() { check(messages).single.identicalTo(message); check(store.messages).deepEquals({1: message}); }); + + test('matchContent and matchTopic are removed', () async { + await prepare(); + final message1 = eg.streamMessage(id: 1, content: '

foo

'); + await addMessages([message1]); + check(store.messages).deepEquals({1: message1}); + final otherMessage1 = eg.streamMessage(id: 1, content: '

foo

', + matchContent: 'some highlighted content', + matchTopic: 'some highlighted topic'); + final message2 = eg.streamMessage(id: 2, content: '

bar

', + matchContent: 'some highlighted content', + matchTopic: 'some highlighted topic'); + final messages = [otherMessage1, message2]; + store.reconcileMessages(messages); + + Condition conditionIdenticalAndNullMatchFields(Message message) { + return (it) => it.isA() + ..identicalTo(message) + ..matchContent.isNull()..matchTopic.isNull(); + } + + check(messages).deepEquals([ + conditionIdenticalAndNullMatchFields(message1), + conditionIdenticalAndNullMatchFields(message2), + ]); + + check(store.messages).deepEquals({ + 1: conditionIdenticalAndNullMatchFields(message1), + 2: conditionIdenticalAndNullMatchFields(message2), + }); + }); }); group('edit-message methods', () { 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/realm_test.dart b/test/model/realm_test.dart new file mode 100644 index 0000000000..5aecfd289d --- /dev/null +++ b/test/model/realm_test.dart @@ -0,0 +1,78 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../example_data.dart' as eg; + +void main() { + group('customProfileFields', () { + test('update clobbers old list', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); + + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(2, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); + }); + + test('sorts by displayInProfile', () async { + // Sorts both the data from the initial snapshot… + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); + + // … and from an event. + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); + }); + }); + + test('processTopicLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + + TopicName process(TopicName topic, int zulipFeatureLevel) { + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName)); + return store.processTopicLikeServer(topic); + } + + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(process(topic, zulipFeatureLevel)).equals(expected); + } + + check(() => process(eg.t(''), 333)).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); + }); +} diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index 602362cbcc..990811d216 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -1,13 +1,16 @@ import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/channel.dart'; import 'package:zulip/model/recent_senders.dart'; import '../example_data.dart' as eg; /// [messages] should be sorted by [id] ascending. void checkMatchesMessages(RecentSenders model, List messages) { final Map>> messagesByUserInStream = {}; - final Map>>> messagesByUserInTopic = {}; + final Map>>> messagesByUserInTopic = {}; for (final message in messages) { if (message is! StreamMessage) { throw UnsupportedError('Message of type ${message.runtimeType} is not expected.'); @@ -17,7 +20,7 @@ void checkMatchesMessages(RecentSenders model, List messages) { ((messagesByUserInStream[streamId] ??= {}) [senderId] ??= {}).add(messageId); - (((messagesByUserInTopic[streamId] ??= {})[topic] ??= {}) + (((messagesByUserInTopic[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= {}).add(messageId); } @@ -125,6 +128,16 @@ void main() { [eg.streamMessage(stream: streamA, topic: 'other', sender: userX)]); }); + test('case-insensitive topics', () { + checkHandleMessages( + [eg.streamMessage(stream: streamA, topic: 'thing', sender: userX)], + [eg.streamMessage(stream: streamA, topic: 'ThInG', sender: userX)]); + check(model.topicSenders).values.single.deepEquals( + {eg.t('thing'): + {userX.userId: (Subject it) => + it.isA().ids.length.equals(2)}}); + }); + test('add new stream', () { checkHandleMessages( [eg.streamMessage(stream: streamA, topic: 'thing', sender: userX)], @@ -161,6 +174,16 @@ void main() { Map.fromEntries(messages.map((msg) => MapEntry(msg.id, msg)))); checkMatchesMessages(model, [messages[1]]); + + // check case-insensitivity + model.handleDeleteMessageEvent(DeleteMessageEvent( + id: 0, + messageIds: [messages[1].id], + messageType: MessageType.stream, + streamId: stream.streamId, + topic: eg.t('oThEr'), + ), {messages[1].id: messages[1]}); + checkMatchesMessages(model, []); }); test('RecentSenders.latestMessageIdOfSenderInStream', () { @@ -200,6 +223,9 @@ void main() { check(model.latestMessageIdOfSenderInTopic(streamId: 1, topic: eg.t('a'), senderId: 10)).equals(300); + // case-insensitivity + check(model.latestMessageIdOfSenderInTopic(streamId: 1, + topic: eg.t('A'), senderId: 10)).equals(300); // No message of user 20 in topic "a". check(model.latestMessageIdOfSenderInTopic(streamId: 1, topic: eg.t('a'), senderId: 20)).equals(null); @@ -211,3 +237,7 @@ void main() { topic: eg.t('a'), senderId: 10)).equals(null); }); } + +extension MessageIdTrackerChecks on Subject { + Subject> get ids => has((x) => x.ids, 'ids'); +} 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 93e24dffdd..6321fa057e 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -32,6 +32,8 @@ extension GlobalSettingsStoreChecks on Subject { Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); + Subject get visitFirstUnread => has((x) => x.visitFirstUnread, 'visitFirstUnread'); + Subject get markReadOnScroll => has((x) => x.markReadOnScroll, 'markReadOnScroll'); Subject getBool(BoolGlobalSetting setting) => has((x) => x.getBool(setting), 'getBool(${setting.name}'); } @@ -45,7 +47,7 @@ extension GlobalStoreChecks on Subject { extension PerAccountStoreChecks on Subject { Subject get connection => has((x) => x.connection, 'connection'); - Subject get isLoading => has((x) => x.isLoading, 'isLoading'); + Subject get isRecoveringEventStream => has((x) => x.isRecoveringEventStream, 'isRecoveringEventStream'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); Subject get realmMandatoryTopics => has((x) => x.realmMandatoryTopics, 'realmMandatoryTopics'); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index eba1505747..c90b45a25f 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -13,10 +13,10 @@ 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/route/events.dart'; -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 +31,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); @@ -463,132 +464,12 @@ void main() { }); }); - group('PerAccountStore.hasPassedWaitingPeriod', () { - final store = eg.store(initialSnapshot: - eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); - - final testCases = [ - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), - ]; - - for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { - test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' - 'passed waiting period by $currentDate', () { - final user = eg.user(dateJoined: dateJoined); - check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) - .equals(hasPassedWaitingPeriod); - }); - } - }); - - group('PerAccountStore.hasPostingPermission', () { - final testCases = [ - (ChannelPostPolicy.unknown, UserRole.unknown, true), - (ChannelPostPolicy.unknown, UserRole.guest, true), - (ChannelPostPolicy.unknown, UserRole.member, true), - (ChannelPostPolicy.unknown, UserRole.moderator, true), - (ChannelPostPolicy.unknown, UserRole.administrator, true), - (ChannelPostPolicy.unknown, UserRole.owner, true), - (ChannelPostPolicy.any, UserRole.unknown, true), - (ChannelPostPolicy.any, UserRole.guest, true), - (ChannelPostPolicy.any, UserRole.member, true), - (ChannelPostPolicy.any, UserRole.moderator, true), - (ChannelPostPolicy.any, UserRole.administrator, true), - (ChannelPostPolicy.any, UserRole.owner, true), - (ChannelPostPolicy.fullMembers, UserRole.unknown, true), - (ChannelPostPolicy.fullMembers, UserRole.guest, false), - // The fullMembers/member case gets its own tests further below. - // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), - (ChannelPostPolicy.fullMembers, UserRole.moderator, true), - (ChannelPostPolicy.fullMembers, UserRole.administrator, true), - (ChannelPostPolicy.fullMembers, UserRole.owner, true), - (ChannelPostPolicy.moderators, UserRole.unknown, true), - (ChannelPostPolicy.moderators, UserRole.guest, false), - (ChannelPostPolicy.moderators, UserRole.member, false), - (ChannelPostPolicy.moderators, UserRole.moderator, true), - (ChannelPostPolicy.moderators, UserRole.administrator, true), - (ChannelPostPolicy.moderators, UserRole.owner, true), - (ChannelPostPolicy.administrators, UserRole.unknown, true), - (ChannelPostPolicy.administrators, UserRole.guest, false), - (ChannelPostPolicy.administrators, UserRole.member, false), - (ChannelPostPolicy.administrators, UserRole.moderator, false), - (ChannelPostPolicy.administrators, UserRole.administrator, true), - (ChannelPostPolicy.administrators, UserRole.owner, true), - ]; - - for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { - test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' - 'with "${policy.name}" policy', () { - final store = eg.store(); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), - // [byDate] is not actually relevant for these test cases; for the - // ones which it is, they're practiced below. - byDate: DateTime.now()); - check(actual).equals(canPost); - }); - } - - group('"member" user posting in a channel with "fullMembers" policy', () { - PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => - eg.store(initialSnapshot: eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); - - User memberUser({required String dateJoined}) => eg.user( - role: UserRole.member, dateJoined: dateJoined); - - test('a "full" member -> can post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final hasPermission = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 10, 00)); - check(hasPermission).isTrue(); - }); - - test('not a "full" member -> cannot post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 09, 59)); - check(actual).isFalse(); - }); - }); - }); - group('PerAccountStore.handleEvent', () { // Mostly this method just dispatches to ChannelStore and MessageStore etc., // and so its tests generally live in the test files for those // (but they call the handleEvent method because it's the entry point). }); - group('PerAccountStore.sendMessage', () { - test('smoke', () async { - final store = eg.store(); - final connection = store.connection as FakeApiConnection; - final stream = eg.stream(); - connection.prepare(json: SendMessageResult(id: 12345).toJson()); - await store.sendMessage( - destination: StreamDestination(stream.streamId, eg.t('world')), - content: 'hello'); - check(connection.takeRequests()).single.isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages') - ..bodyFields.deepEquals({ - 'type': 'stream', - 'to': stream.streamId.toString(), - 'topic': 'world', - 'content': 'hello', - 'read_by_sender': 'true', - }); - }); - }); - group('UpdateMachine.load', () { late TestGlobalStore globalStore; late FakeApiConnection connection; @@ -777,13 +658,14 @@ void main() { updateMachine.poll(); } - void checkLastRequest({required int lastEventId}) { + void checkLastRequest({required int lastEventId, bool expectDontBlock = false}) { check(connection.takeRequests()).single.isA() ..method.equals('GET') ..url.path.equals('/api/v1/events') ..url.queryParameters.deepEquals({ 'queue_id': store.queueId, 'last_event_id': lastEventId.toString(), + if (expectDontBlock) 'dont_block': 'true', }); } @@ -816,14 +698,16 @@ void main() { await preparePoll(); // Pick some arbitrary event and check it gets processed on the store. - check(store.userSettings!.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); })); void checkReload(FutureOr Function() prepareError, { @@ -836,7 +720,7 @@ void main() { await prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); if (expectBackoff) { // The reload doesn't happen immediately; there's a timer. @@ -848,19 +732,21 @@ void main() { // The global store has a new store. check(globalStore.perAccountSync(store.accountId)).not((it) => it.identicalTo(store)); updateFromGlobalStore(); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); // The new UpdateMachine updates the new store. updateMachine.debugPauseLoop(); updateMachine.poll(); - check(store.userSettings!.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); }); } @@ -873,8 +759,8 @@ void main() { prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + checkLastRequest(lastEventId: 1, expectDontBlock: false); + check(store).isRecoveringEventStream.isTrue(); // Polling doesn't resume immediately; there's a timer. check(async.pendingTimers).length.equals(1); @@ -888,9 +774,9 @@ void main() { HeartbeatEvent(id: 2), ], queueId: null).toJson()); async.flushTimers(); - checkLastRequest(lastEventId: 1); + checkLastRequest(lastEventId: 1, expectDontBlock: true); check(updateMachine.lastEventId).equals(2); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); }); } @@ -1027,11 +913,13 @@ void main() { await preparePoll(lastEventId: 1); } - void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true}) { + void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true, bool expectDontBlock = false}) { updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - if (shouldCheckRequest) checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + if (shouldCheckRequest) { + checkLastRequest(lastEventId: 1, expectDontBlock: expectDontBlock); + } + check(store).isRecoveringEventStream.isTrue(); } Subject checkReported(void Function() prepareError) { @@ -1049,9 +937,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -1059,11 +949,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); return check(takeLastReportedError()).isNotNull(); }); } @@ -1072,9 +965,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -1082,11 +977,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); // Still no error reported, even after the same number of iterations // where other errors get reported (as [checkLateReported] checks). check(takeLastReportedError()).isNull(); @@ -1181,7 +1079,7 @@ void main() { globalStore.clearCachedApiConnections(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); // the bad-event-queue error arrives - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); } test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { @@ -1290,8 +1188,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 +1216,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..18e41bcfb5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -267,6 +267,20 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future setMutedUsers(List userIds) async { + await handleEvent(eg.mutedUsersEvent(userIds)); + } + + Future changeUserStatus(int userId, UserStatusChange change) async { + await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); + } + + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { + await changeUserStatus(userId, change); + } + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } @@ -283,7 +297,7 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(SubscriptionAddEvent(id: 1, subscriptions: subscriptions)); } - Future addUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { + Future setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { await handleEvent(eg.userTopicEvent(stream.streamId, topic, visibilityPolicy)); } diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 01d817680a..8d293940ad 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -297,7 +297,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); async.elapse(waitTime); // t = 100ms: The idle timer is reset to typingStoppedWaitPeriod. @@ -306,7 +306,7 @@ void main() { check(connection.lastRequest).isNull(); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - const Duration(milliseconds: 1)); + async.elapse(store.serverTypingStoppedWaitPeriod - const Duration(milliseconds: 1)); // t = typingStoppedWaitPeriod + 99ms: // Since the timer was reset at t = 100ms, the "typing stopped" notice has // not been sent yet. @@ -326,12 +326,12 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // [waitInterval] should be short enough // that the loop below runs more than once. - assert(waitInterval < model.typingStartedWaitPeriod); + assert(waitInterval < store.serverTypingStartedWaitPeriod); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "Typing started" notices are throttled. model.keystroke(narrow); check(connection.lastRequest).isNull(); @@ -354,7 +354,7 @@ void main() { await prepareStartTyping(async); connection.prepare(json: {}); - async.elapse(model.typingStoppedWaitPeriod); + async.elapse(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); check(async.pendingTimers).isEmpty(); })); @@ -406,7 +406,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The idle timer is set to typingStoppedWaitPeriod. connection.prepare(json: {}); @@ -429,7 +429,7 @@ void main() { async.elapse(Duration.zero); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - waitTime); + async.elapse(store.serverTypingStoppedWaitPeriod - waitTime); // t = typingStoppedPeriod: // Because the old timer has been canceled at t = 100ms, // no "typing stopped" notice has been sent yet. @@ -452,7 +452,7 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The typing started time is set to 0ms. connection.prepare(json: {}); @@ -471,7 +471,7 @@ void main() { checkSetTypingStatusRequests(connection.takeRequests(), [(TypingOp.stop, topicNarrow), (TypingOp.start, dmNarrow)]); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "still typing" requests are throttled. model.keystroke(dmNarrow); check(connection.lastRequest).isNull(); @@ -479,8 +479,8 @@ void main() { async.elapse(waitInterval); } - assert(async.elapsed > model.typingStartedWaitPeriod); - assert(async.elapsed <= model.typingStartedWaitPeriod + waitInterval); + assert(async.elapsed > store.serverTypingStartedWaitPeriod); + assert(async.elapsed <= store.serverTypingStartedWaitPeriod + waitInterval); // typingStartedWaitPeriod < t <= typingStartedWaitPeriod + waitInterval * 1: // The "still typing" requests are still throttled, because it hasn't // been a full typingStartedWaitPeriod since the last time we sent diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index de554b220c..e1c5ca6986 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -4,14 +4,29 @@ import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/algorithms.dart'; +import 'package:zulip/model/channel.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/unreads.dart'; import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'unreads_checks.dart'; +void checkInvariants(Unreads model) { + for (final MapEntry(value: topics) in model.streams.entries) { + for (final MapEntry(value: messageIds) in topics.entries) { + check(isSortedWithoutDuplicates(messageIds)).isTrue(); + } + } + + for (final MapEntry(value: messageIds) in model.dms.entries) { + check(isSortedWithoutDuplicates(messageIds)).isTrue(); + } +} + void main() { // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. @@ -23,7 +38,10 @@ void main() { check(notifiedCount).equals(count); notifiedCount = 0; } - void checkNotNotified() => checkNotified(count: 0); + void checkNotNotified() { + checkInvariants(model); + checkNotified(count: 0); + } void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] and the rest of the test state. @@ -38,15 +56,18 @@ void main() { ), }) { store = eg.store(initialSnapshot: eg.initialSnapshot(unreadMsgs: initial)); + checkInvariants(store.unreads); notifiedCount = 0; model = store.unreads ..addListener(() { + checkInvariants(model); notifiedCount++; }); checkNotNotified(); } - void fillWithMessages(Iterable messages) { + void fillWithMessages(List messages) { + check(isSortedWithoutDuplicates(messages.map((m) => m.id).toList())).isTrue(); for (final message in messages) { model.handleMessageEvent(eg.messageEvent(message)); } @@ -57,7 +78,7 @@ void main() { assert(Set.of(messages.map((m) => m.id)).length == messages.length, 'checkMatchesMessages: duplicate messages in test input'); - final Map>> expectedStreams = {}; + final Map>> expectedStreams = {}; final Map> expectedDms = {}; final Set expectedMentions = {}; for (final message in messages) { @@ -66,7 +87,7 @@ void main() { } switch (message) { case StreamMessage(): - final perTopic = expectedStreams[message.streamId] ??= {}; + final perTopic = expectedStreams[message.streamId] ??= makeTopicKeyedMap(); final messageIds = perTopic[message.topic] ??= QueueList(); messageIds.add(message.id); case DmMessage(): @@ -117,16 +138,19 @@ void main() { eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]), eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]), eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]), + + // TODO(server-10) drop this (see implementation) + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'C', unreadMessageIds: [9, 10]), ], dms: [ - UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]), - UnreadDmSnapshot(otherUserId: 2, unreadMessageIds: [11, 12]), + UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [11, 12]), + UnreadDmSnapshot(otherUserId: 2, unreadMessageIds: [13, 14]), ], huddles: [ - UnreadHuddleSnapshot(userIdsString: '1,2,${eg.selfUser.userId}', unreadMessageIds: [13, 14]), - UnreadHuddleSnapshot(userIdsString: '2,3,${eg.selfUser.userId}', unreadMessageIds: [15, 16]), + UnreadHuddleSnapshot(userIdsString: '1,2,${eg.selfUser.userId}', unreadMessageIds: [15, 16]), + UnreadHuddleSnapshot(userIdsString: '2,3,${eg.selfUser.userId}', unreadMessageIds: [17, 18]), ], - mentions: [6, 12, 16], + mentions: [6, 14, 18], oldUnreadsMissing: false, )); checkMatchesMessages([ @@ -138,14 +162,16 @@ void main() { eg.streamMessage(id: 6, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]), eg.streamMessage(id: 7, stream: stream2, topic: 'c', flags: []), eg.streamMessage(id: 8, stream: stream2, topic: 'c', flags: []), - eg.dmMessage(id: 9, from: user1, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 10, from: user1, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 11, from: user2, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 12, from: user2, to: [eg.selfUser], flags: [MessageFlag.mentioned]), - eg.dmMessage(id: 13, from: user1, to: [user2, eg.selfUser], flags: []), - eg.dmMessage(id: 14, from: user1, to: [user2, eg.selfUser], flags: []), - eg.dmMessage(id: 15, from: user2, to: [user3, eg.selfUser], flags: []), - eg.dmMessage(id: 16, from: user2, to: [user3, eg.selfUser], flags: [MessageFlag.wildcardMentioned]), + eg.streamMessage(id: 9, stream: stream2, topic: 'C', flags: []), + eg.streamMessage(id: 10, stream: stream2, topic: 'C', flags: []), + eg.dmMessage(id: 11, from: user1, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 12, from: user1, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 13, from: user2, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 14, from: user2, to: [eg.selfUser], flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 15, from: user1, to: [user2, eg.selfUser], flags: []), + eg.dmMessage(id: 16, from: user1, to: [user2, eg.selfUser], flags: []), + eg.dmMessage(id: 17, from: user2, to: [user3, eg.selfUser], flags: []), + eg.dmMessage(id: 18, from: user2, to: [user3, eg.selfUser], flags: [MessageFlag.wildcardMentioned]), ]); }); }); @@ -160,7 +186,7 @@ void main() { await store.addSubscription(eg.subscription(stream1)); await store.addSubscription(eg.subscription(stream2)); await store.addSubscription(eg.subscription(stream3, isMuted: true)); - await store.addUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted); fillWithMessages([ eg.streamMessage(stream: stream1, topic: 'a', flags: []), eg.streamMessage(stream: stream1, topic: 'b', flags: []), @@ -178,14 +204,14 @@ void main() { prepare(); await store.addStream(stream); await store.addSubscription(eg.subscription(stream)); - await store.addUserTopic(stream, 'a', UserTopicVisibilityPolicy.unmuted); - await store.addUserTopic(stream, 'c', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'a', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'c', UserTopicVisibilityPolicy.muted); fillWithMessages([ eg.streamMessage(stream: stream, topic: 'a', flags: []), - eg.streamMessage(stream: stream, topic: 'a', flags: []), - eg.streamMessage(stream: stream, topic: 'b', flags: []), + eg.streamMessage(stream: stream, topic: 'A', flags: []), eg.streamMessage(stream: stream, topic: 'b', flags: []), eg.streamMessage(stream: stream, topic: 'b', flags: []), + eg.streamMessage(stream: stream, topic: 'B', flags: []), eg.streamMessage(stream: stream, topic: 'c', flags: []), ]); check(model.countInChannel (stream.streamId)).equals(5); @@ -201,9 +227,13 @@ void main() { test('countInTopicNarrow', () { final stream = eg.stream(); prepare(); - fillWithMessages(List.generate(7, (i) => eg.streamMessage( - stream: stream, topic: 'a', flags: []))); - check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(7); + final messages = [ + ...List.generate(7, (i) => eg.streamMessage(stream: stream, topic: 'a', flags: [])), + ...List.generate(2, (i) => eg.streamMessage(stream: stream, topic: 'A', flags: [])), + ]; + fillWithMessages(messages); + check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(9); + check(model.countInTopicNarrow(stream.streamId, eg.t('A'))).equals(9); }); test('countInDmNarrow', () { @@ -242,9 +272,9 @@ void main() { group('isUnread', () { final unreadDmMessage = eg.dmMessage( from: eg.otherUser, to: [eg.selfUser], flags: []); + final unreadChannelMessage = eg.streamMessage(flags: []); final readDmMessage = eg.dmMessage( from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.read]); - final unreadChannelMessage = eg.streamMessage(flags: []); final readChannelMessage = eg.streamMessage(flags: [MessageFlag.read]); final allMessages = [ @@ -351,6 +381,24 @@ void main() { }); } }); + + test('topics case-insensitive but case-preserving', () { + final stream = eg.stream(); + final message1 = eg.streamMessage(stream: stream, topic: 'aaa'); + final message2 = eg.streamMessage(stream: stream, topic: 'AaA'); + final message3 = eg.streamMessage(stream: stream, topic: 'aAa'); + prepare(); + fillWithMessages([message1]); + model.handleMessageEvent(eg.messageEvent(message2)); + model.handleMessageEvent(eg.messageEvent(message3)); + checkNotified(count: 2); + checkMatchesMessages([message1, message2, message3]); + // Redundant with checkMatchesMessages, but for explicitness here: + check(model).streams.values.single + .entries.single + ..key.equals(eg.t('aaa')) + ..value.length.equals(3); + }); }); group('DM messages', () { @@ -481,11 +529,11 @@ void main() { prepare(); await store.addStream(origChannel); await store.addSubscription(eg.subscription(origChannel)); + unreadMessages = List.generate(10, + (_) => eg.streamMessage(stream: origChannel, topic: origTopic)); readMessages = List.generate(10, (_) => eg.streamMessage(stream: origChannel, topic: origTopic, flags: [MessageFlag.read])); - unreadMessages = List.generate(10, - (_) => eg.streamMessage(stream: origChannel, topic: origTopic)); } List copyMessagesWith(Iterable messages, { @@ -600,17 +648,49 @@ void main() { test('tolerates unsorted messages', () async { await prepareStore(); final unreadMessages = List.generate(10, (i) => - eg.streamMessage( - id: 1000 - i, stream: origChannel, topic: origTopic)); + eg.streamMessage(stream: origChannel, topic: origTopic)); fillWithMessages(unreadMessages); model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( - origMessages: unreadMessages, + origMessages: unreadMessages.reversed.toList(), newTopicStr: newTopic)); checkNotifiedOnce(); checkMatchesMessages(copyMessagesWith(unreadMessages, newTopic: newTopic)); }); + test('topics case-insensitive but case-preserving', () async { + final message1 = eg.streamMessage(stream: origChannel, topic: 'aaa', flags: []); + final message2 = eg.streamMessage(stream: origChannel, topic: 'aaa', flags: []); + final messages = [message1, message2]; + await prepareStore(); + fillWithMessages(messages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + // 'AAA' finds the key 'aaa' + origMessages: copyMessagesWith([message1], newTopic: 'AAA'), + newTopicStr: 'bbb')); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith([message1], newTopic: 'bbb'), + message2, + ]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: [message2], + // 'BBB' finds the key 'bbb' + newTopicStr: 'BBB')); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith([message1], newTopic: 'bbb'), + ...copyMessagesWith([message2], newTopic: 'BBB'), + ]); + // Redundant with checkMatchesMessages, but for explicitness here: + check(model).streams.values.single + .entries.single + ..key.equals(eg.t('bbb')) + ..value.length.equals(2); + }); + test('tolerates unreads unknown to the model', () async { await prepareStore(); fillWithMessages(unreadMessages); @@ -672,6 +752,7 @@ void main() { fillWithMessages(messages); final expectedRemainingMessages = Set.of(messages); + assert(messages.any((m) => m.id == 14)); for (final message in messages) { final event = switch (message) { StreamMessage() => DeleteMessageEvent( @@ -679,7 +760,12 @@ void main() { messageType: MessageType.stream, messageIds: [message.id], streamId: message.streamId, - topic: message.topic, + topic: () { + if (message.id != 14) return message.topic; + final uppercase = message.topic.apiName.toUpperCase(); + assert(message.topic.apiName != uppercase); + return eg.t(uppercase); // exercise case-insensitivity of topics + }(), ), DmMessage() => DeleteMessageEvent( id: 0, diff --git a/test/model/user_group_test.dart b/test/model/user_group_test.dart new file mode 100644 index 0000000000..f05b3c3c32 --- /dev/null +++ b/test/model/user_group_test.dart @@ -0,0 +1,119 @@ +import 'package:checks/checks.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/user_group.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; + +void main() { + List sorted(Iterable groups) { + return groups.toList()..sort((a, b) => a.id.compareTo(b.id)); + } + + void checkGroupsEqual(UserGroupStore store, Iterable expected) { + check(sorted(store.allGroups)).jsonEquals(expected); + } + + test('initialize', () { + final groups = [eg.userGroup(), eg.userGroup()]; + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: groups)); + checkGroupsEqual(store, groups); + }); + + test('getGroup', () { + final group1 = eg.userGroup(); + final group2 = eg.userGroup(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1, group2])); + check(store.getGroup(group1.id)).jsonEquals(group1); + check(store.getGroup(group2.id)).jsonEquals(group2); + check(store.getGroup(eg.userGroup().id)).isNull(); + }); + + test('activeGroups, allGroups', () async { + final group1 = eg.userGroup(deactivated: false); + final group2 = eg.userGroup(deactivated: true); + final group3 = eg.userGroup(deactivated: false); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1, group2, group3])); + check(sorted(store.allGroups)).jsonEquals([group1, group2, group3]); + check(sorted(store.activeGroups)).jsonEquals([group1, group3]); + + await store.handleEvent(UserGroupUpdateEvent(id: 1, groupId: group1.id, + data: UserGroupUpdateData(name: null, description: null, deactivated: true))); + check(sorted(store.activeGroups)).jsonEquals([group3]); + }); + + test('UserGroupAddEvent, UserGroupRemoveEvent', () async { + final group1 = eg.userGroup(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1])); + checkGroupsEqual(store, [group1]); + + final group2 = eg.userGroup(); + await store.handleEvent(UserGroupAddEvent(id: 1, group: group2)); + checkGroupsEqual(store, [group1, group2]); + + await store.handleEvent(UserGroupRemoveEvent(id: 2, groupId: group1.id)); + checkGroupsEqual(store, [group2]); + }); + + test('UserGroupUpdateEvent', () async { + final store = eg.store(); + final group = eg.userGroup( + name: 'a group', description: 'is a group', deactivated: false); + await store.handleEvent(UserGroupAddEvent(id: 1, group: group)); + checkGroupsEqual(store, [group]); + + // Handles all the properties being updated at once. + await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id, + data: UserGroupUpdateData(name: 'revised group', + description: 'different description', deactivated: true))); + checkGroupsEqual(store, [{ + ...group.toJson(), + 'name': 'revised group', + 'description': 'different description', + 'deactivated': true, + }]); + + // Handles some properties being null, still updating the one that's present. + await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id, + data: UserGroupUpdateData(name: null, + description: null, deactivated: false))); + checkGroupsEqual(store, [{ + ...group.toJson(), + 'name': 'revised group', + 'description': 'different description', + 'deactivated': false, + }]); + }); + + test('various fields make it through', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [ + eg.userGroup(id: 3, name: 'some group', description: 'this is a group', + isSystemGroup: true, deactivated: false), + ])); + await store.handleEvent(UserGroupAddEvent(id: 1, group: eg.userGroup( + id: 5, name: 'a different group', description: 'also a group', + isSystemGroup: false, deactivated: true))); + check(sorted(store.allGroups)).deepEquals(>[ + (it) => it.isA() + ..id.equals(3) + ..name.equals('some group') + ..description.equals('this is a group') + ..isSystemGroup.isTrue() + ..deactivated.isFalse(), + (it) => it.isA() + ..id.equals(5) + ..name.equals('a different group') + ..description.equals('also a group') + ..isSystemGroup.isFalse() + ..deactivated.isTrue(), + ]); + }); +} diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 63ac1589c7..3638e3c214 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -2,11 +2,17 @@ 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 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/model/user.dart'; import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; import 'test_store.dart'; +typedef StatusData = (String? statusText, String? emojiName, String? emojiCode, + String? reactionType); + void main() { group('userDisplayName', () { test('on a known user', () async { @@ -52,6 +58,28 @@ void main() { }); }); + group('hasPassedWaitingPeriod', () { + final store = eg.store(initialSnapshot: + eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); + + final testCases = [ + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), + ]; + + for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { + test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' + 'passed waiting period by $currentDate', () { + final user = eg.user(dateJoined: dateJoined); + check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) + .equals(hasPassedWaitingPeriod); + }); + } + }); + group('RealmUserUpdateEvent', () { // TODO write more tests for handling RealmUserUpdateEvent @@ -79,4 +107,147 @@ void main() { check(getUser()).deliveryEmail.equals('c@mail.example'); }); }); + + testWidgets('UserStatusEvent', (tester) async { + UserStatusChange userStatus(StatusData data) => UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + }); + + void checkUserStatus(UserStatus userStatus, StatusData expected) { + check(userStatus).text.equals(expected.$1); + + switch (expected) { + case (_, String emojiName, String emojiCode, String reactionType): + check(userStatus.emoji!) + ..emojiName.equals(emojiName) + ..emojiCode.equals(emojiCode) + ..reactionType.equals(ReactionType.fromApiValue(reactionType)); + default: + check(userStatus.emoji).isNull(); + } + } + + UserStatusEvent userStatusEvent(StatusData data, {required int userId}) => + UserStatusEvent( + id: 1, + userId: userId, + change: UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + })); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + userStatuses: { + 1: userStatus(('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')), + 2: userStatus((null, 'calendar', '1f4c5', 'unicode_emoji')), + 3: userStatus(('Commuting', null, null, null)), + })); + checkUserStatus(store.getUserStatus(1), + ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(2), + (null, 'calendar', '1f4c5', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(3), + ('Commuting', null, null, null)); + check(store.getUserStatus(4))..text.isNull()..emoji.isNull(); + check(store.getUserStatus(5))..text.isNull()..emoji.isNull(); + + await store.handleEvent(userStatusEvent(userId: 1, + ('Out sick', 'sick', '1f912', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(1), + ('Out sick', 'sick', '1f912', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 2, + ('In a meeting', null, null, null))); + checkUserStatus(store.getUserStatus(2), + ('In a meeting', 'calendar', '1f4c5', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 3, + ('', 'bus', '1f68c', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(3), + (null, 'bus', '1f68c', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 4, + ('Vacationing', null, null, null))); + checkUserStatus(store.getUserStatus(4), + ('Vacationing', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 5, + ('Working remotely', '', '', ''))); + checkUserStatus(store.getUserStatus(5), + ('Working remotely', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 1, + ('', '', '', ''))); + checkUserStatus(store.getUserStatus(1), + (null, null, null, null)); + }); + + group('MutedUsersEvent', () { + testWidgets('smoke', (tester) async { + late PerAccountStore store; + + void checkDmConversationMuted(List otherUserIds, bool expected) { + final narrow = DmNarrow.withOtherUsers(otherUserIds, selfUserId: store.selfUserId); + check(store.shouldMuteDmConversation(narrow)).equals(expected); + } + + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + 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(); + checkDmConversationMuted([1], true); + checkDmConversationMuted([1, 2], true); + checkDmConversationMuted([2, 3], false); + checkDmConversationMuted([1, 2, 3], false); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1, 2, 3], true); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store.isUserMuted(1)).isFalse(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1], false); + checkDmConversationMuted([], false); + }); + + group('mightChangeShouldMuteDmConversation', () { + void doTest( + String description, + List before, + List after, + MutedUsersVisibilityEffect expected, + ) { + testWidgets(description, (tester) async { + final store = eg.store(); + await store.addUser(eg.selfUser); + await store.addUsers(before.map((id) => eg.user(userId: id))); + await store.setMutedUsers(before); + final event = eg.mutedUsersEvent(after); + check(store.mightChangeShouldMuteDmConversation(event)).equals(expected); + }); + } + + doTest('none', [1], [1], MutedUsersVisibilityEffect.none); + doTest('none (empty to empty)', [], [], MutedUsersVisibilityEffect.none); + doTest('muted', [1], [1, 2], MutedUsersVisibilityEffect.muted); + doTest('unmuted', [1, 2], [1], MutedUsersVisibilityEffect.unmuted); + doTest('mixed', [1, 2, 3], [1, 2, 4], MutedUsersVisibilityEffect.mixed); + doTest('mixed (all replaced)', [1], [2], MutedUsersVisibilityEffect.mixed); + }); + }); } 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..cdfd8ef361 --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,576 @@ +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/checks.dart'; +import '../widgets/dialog_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/stdlib_checks.dart b/test/stdlib_checks.dart index 8bfaea54fd..28b5f5e5f2 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -16,6 +16,11 @@ extension ListChecks on Subject> { Subject operator [](int index) => has((l) => l[index], '[$index]'); } +extension MapEntryChecks on Subject> { + Subject get key => has((e) => e.key, 'key'); + Subject get value => has((e) => e.value, 'value'); +} + extension NullableMapChecks on Subject?> { void deepEquals(Map? expected) { if (expected == null) { 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 6b8011510a..631856efde 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -26,23 +26,25 @@ 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'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/content_test.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 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -53,27 +55,37 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? selfUser, + 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)); + // TODO(#1667) will be null in a search narrow; remove `!`. + assert(narrow.containsMessage(message)!); + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add( - eg.selfAccount, + selfAccount, eg.initialSnapshot( realmAllowMessageEditing: realmAllowMessageEditing, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, )); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ - eg.selfUser, - eg.user(userId: message.senderId), + selfUser, + 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); @@ -88,12 +100,14 @@ Future setupToMessageActionSheet(WidgetTester tester, { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); + await beforeLongPress?.call(); + // Request the message action sheet. // // We use `warnIfMissed: false` to suppress warnings in cases where @@ -114,6 +128,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { void main() { TestZulipBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; void prepareRawContentResponseSuccess({ required Message message, @@ -226,6 +241,7 @@ void main() { group('showChannelActionSheet', () { void checkButtons() { check(actionSheetFinder).findsOne(); + checkButton('List of topics'); checkButton('Mark channel as read'); } @@ -244,7 +260,7 @@ void main() { testWidgets('show with no unread messages', (tester) async { await prepare(hasUnreadMessages: false); await showFromSubscriptionList(tester); - check(actionSheetFinder).findsNothing(); + check(findButtonForLabel('Mark channel as read')).findsNothing(); }); testWidgets('show from app bar in channel narrow', (tester) async { @@ -268,6 +284,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() @@ -835,6 +864,81 @@ void main() { }); group('message action sheet', () { + group('header', () { + void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) { + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == senderId)) + ).findsOne(); + final expectedTimestampColor = MessageListTheme.of( + tester.element(find.byType(BottomSheet))).labelTime; + // TODO check the timestamp text itself, when it's convenient to do so: + // https://github.com/zulip/zulip-flutter/pull/1624#discussion_r2181383754 + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byWidgetPredicate((widget) => + widget is Text + && widget.style?.color == expectedTimestampColor + && (widget.style?.fontFeatures?.contains(FontFeature.enable('c2sc')) ?? false))) + ).findsOne(); + } + + testWidgets('message sender and content shown', (tester) async { + final message = eg.streamMessage( + timestamp: 1671409088, + content: ContentExample.userMentionPlain.html); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(UserMention)) + ).findsOne(); + }); + + testWidgets('muted sender also shown', (tester) async { + final message = eg.streamMessage( + timestamp: 1671409088, + content: ContentExample.userMentionPlain.html); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + mutedUserIds: [message.senderId], + beforeLongPress: () async { + check(find.byType(MessageContent)).findsNothing(); + await tester.tap( + find.widgetWithText(ZulipWebUiKitButton, 'Reveal message')); + await tester.pump(); + check(find.byType(MessageContent)).findsOne(); + }, + ); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(UserMention)) + ).findsOne(); + }); + + testWidgets('poll is rendered', (tester) async { + final submessageContent = eg.pollWidgetData( + question: 'poll', options: ['First option', 'Second option']); + final message = eg.streamMessage( + timestamp: 1671409088, + sender: eg.selfUser, + submessages: [eg.submessage(content: submessageContent)]); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.text('First option')) + ).findsOne(); + }); + }); + group('ReactionButtons', () { testWidgets('absent if ServerEmojiData not loaded', (tester) async { final message = eg.streamMessage(); @@ -1103,11 +1207,13 @@ void main() { }); testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final selfUser = eg.user(role: UserRole.member); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + await setupToMessageActionSheet(tester, selfUser: selfUser, + message: message, narrow: TopicNarrow.ofMessage(message)); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, role: UserRole.guest)); await store.handleEvent(eg.channelUpdateEvent(stream, property: ChannelPropertyName.channelPostPolicy, @@ -1139,7 +1245,8 @@ void main() { }); testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + final otherUser = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [otherUser]); await setupToMessageActionSheet(tester, message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); @@ -1152,7 +1259,7 @@ void main() { await tapQuoteAndReplyButton(tester); await tester.pump(const Duration(seconds: 1)); // message not yet fetched - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: otherUser.userId, isActive: false)); await tester.pump(); // no error @@ -1320,6 +1427,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( @@ -1553,7 +1733,11 @@ void main() { connection.prepare(json: GetMessageResult( message: eg.streamMessage(content: 'foo')).toJson()); await tapEdit(tester); - await tester.pump(Duration.zero); + await tester.pump(); + // Default duration of bottom-sheet exit animation, + // plus 1ms fudge factor (why needed?) + // TODO(#1668) get this dynamically instead of hard-coding + await tester.pump(Duration(milliseconds: 200 + 1)); await tester.enterText(find.byWidgetPredicate( (widget) => widget is TextField && widget.controller?.text == 'foo'), 'bar'); @@ -1658,7 +1842,3 @@ void main() { }); }); } - -extension UnicodeEmojiWidgetChecks on Subject { - Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); -} 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/app_bar_test.dart b/test/widgets/app_bar_test.dart index 099471d4f7..f4178b455a 100644 --- a/test/widgets/app_bar_test.dart +++ b/test/widgets/app_bar_test.dart @@ -31,7 +31,7 @@ void main() { await tester.pumpAndSettle(); final rectBefore = tester.getRect(find.byType(ZulipAppBar)); check(finder.evaluate()).isEmpty(); - store.isLoading = true; + store.isRecoveringEventStream = true; await tester.pump(); check(tester.getRect(find.byType(ZulipAppBar))).equals(rectBefore); diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 7b1388dbec..53c959321e 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -17,7 +17,7 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main() { diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index b4ff007a8d..ddcfc26036 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,15 +7,17 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; -import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -145,6 +149,7 @@ typedef ExpectedEmoji = (String label, EmojiDisplay display); void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; group('@-mentions', () { @@ -201,6 +206,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } diff --git a/test/widgets/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/channel_colors_checks.dart b/test/widgets/channel_colors_checks.dart deleted file mode 100644 index 8c9e8e37ec..0000000000 --- a/test/widgets/channel_colors_checks.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/channel_colors.dart'; - -extension ChannelColorSwatchChecks on Subject { - Subject get base => has((s) => s.base, 'base'); - Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); - Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); - Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); - Subject get barBackground => has((s) => s.barBackground, 'barBackground'); -} diff --git a/test/widgets/channel_colors_test.dart b/test/widgets/channel_colors_test.dart index 46d3527def..c25707508a 100644 --- a/test/widgets/channel_colors_test.dart +++ b/test/widgets/channel_colors_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/channel_colors.dart'; -import 'channel_colors_checks.dart'; +import 'checks.dart'; void main() { group('ChannelColorSwatches', () { diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart new file mode 100644 index 0000000000..69ff9a5f22 --- /dev/null +++ b/test/widgets/checks.dart @@ -0,0 +1,98 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; +import 'package:zulip/widgets/user.dart'; + +extension ChannelColorSwatchChecks on Subject { + Subject get base => has((s) => s.base, 'base'); + Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); + Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); + Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); + Subject get barBackground => has((s) => s.barBackground, 'barBackground'); +} + +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'); +} + +extension RealmContentNetworkImageChecks on Subject { + Subject get src => has((i) => i.src, 'src'); + // TODO others +} + +extension AvatarImageChecks on Subject { + Subject get userId => has((i) => i.userId, 'userId'); +} + +extension AvatarShapeChecks on Subject { + Subject get size => has((i) => i.size, 'size'); + Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); + Subject get child => has((i) => i.child, 'child'); +} + +extension MessageListPageChecks on Subject { + Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); + Subject get initAnchorMessageId => has((x) => x.initAnchorMessageId, 'initAnchorMessageId'); +} + +extension WidgetRouteChecks on Subject> { + Subject get page => has((x) => x.page, 'page'); +} + +extension AccountRouteChecks on Subject> { + Subject get accountId => has((x) => x.accountId, 'accountId'); +} + +extension ProfilePageChecks on Subject { + Subject get userId => has((x) => x.userId, 'userId'); +} + +extension PerAccountStoreWidgetChecks on Subject { + Subject get accountId => has((x) => x.accountId, 'accountId'); + Subject get child => has((x) => x.child, 'child'); +} + +extension UnreadCountBadgeChecks on Subject { + Subject get count => has((b) => b.count, 'count'); + Subject get bold => has((b) => b.bold, 'bold'); + Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); +} + +extension UnicodeEmojiWidgetChecks on Subject { + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +} + +extension EmojiPickerListEntryChecks on Subject { + Subject get emoji => has((x) => x.emoji, 'emoji'); +} diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart deleted file mode 100644 index b93ff7f1bf..0000000000 --- a/test/widgets/compose_box_checks.dart +++ /dev/null @@ -1,21 +0,0 @@ -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 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 679f4de190..f39e74c138 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,6 +3,7 @@ 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'; @@ -15,6 +16,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; @@ -35,12 +37,13 @@ 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 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -55,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); @@ -80,7 +92,11 @@ void main() { connection = store.connection as FakeApiConnection; connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + 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: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); @@ -133,6 +149,64 @@ void main() { 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; @@ -295,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]); @@ -332,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]); @@ -683,7 +761,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -695,7 +773,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -708,7 +786,7 @@ void main() { await checkStartTyping(tester, destinationNarrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, destinationNarrow); }); @@ -723,6 +801,8 @@ void main() { }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -786,7 +866,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); connection.prepare(json: {}); @@ -796,7 +876,7 @@ void main() { // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -829,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'), @@ -883,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); @@ -1401,6 +1485,211 @@ void main() { }); }); + /// Starts an edit interaction from the action sheet's 'Edit message' button. + /// + /// The fetch-raw-content request is prepared with [delay] (default 1s). + Future startEditInteractionFromActionSheet( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + Duration delay = const Duration(seconds: 1), + bool fetchShouldSucceed = true, + }) async { + await tester.longPress(find.byWidgetPredicate((widget) => + widget is MessageWithPossibleSender && widget.item.message.id == messageId)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + final findEditButton = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.ensureVisible(findEditButton); + if (fetchShouldSucceed) { + connection.prepare(delay: delay, + json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); + } else { + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + } + await tester.tap(findEditButton); + await tester.pump(); + await tester.pump(); + connection.takeRequests(); + } + + Future expectAndHandleDiscardConfirmation( + WidgetTester tester, { + required String expectedMessage, + required bool shouldContinue, + }) async { + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: expectedMessage, + expectedActionButtonText: 'Discard'); + if (shouldContinue) { + await tester.tap(find.byWidget(actionButton)); + } else { + await tester.tap(find.byWidget(cancelButton)); + } + } + + group('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'; @@ -1413,12 +1702,17 @@ void main() { Message msgInNarrow(Narrow narrow) { final List messages = [message, dmMessage]; - return messages.where((m) => narrow.containsMessage(m)).single; + 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]); @@ -1451,36 +1745,6 @@ void main() { check(connection.lastRequest).equals(lastRequest); } - /// Starts an interaction from the action sheet's 'Edit message' button. - /// - /// The fetch-raw-content request is prepared with [delay] (default 1s). - Future startInteractionFromActionSheet( - WidgetTester tester, { - required int messageId, - String originalRawContent = 'foo', - Duration delay = const Duration(seconds: 1), - bool fetchShouldSucceed = true, - }) async { - await tester.longPress(find.byWidgetPredicate((widget) => - widget is MessageWithPossibleSender && widget.item.message.id == messageId)); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - final findEditButton = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); - await tester.ensureVisible(findEditButton); - if (fetchShouldSucceed) { - connection.prepare(delay: delay, - json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); - } else { - connection.prepare(apiException: eg.apiBadRequest(), delay: delay); - } - await tester.tap(findEditButton); - await tester.pump(); - await tester.pump(); - connection.takeRequests(); - } - /// Starts an interaction by tapping a failed edit in the message list. Future startInteractionFromRestoreFailedEdit( WidgetTester tester, { @@ -1488,7 +1752,7 @@ void main() { String originalRawContent = 'foo', String newContent = 'bar', }) async { - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: originalRawContent); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, newContent); @@ -1544,7 +1808,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); @@ -1595,19 +1859,12 @@ void main() { testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); - Future expectAndHandleDiscardConfirmation( - WidgetTester tester, { + Future expectAndHandleDiscardForEditConfirmation(WidgetTester tester, { required bool shouldContinue, - }) async { - final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, - expectedTitle: 'Discard the message you’re writing?', + }) { + return expectAndHandleDiscardConfirmation(tester, expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', - expectedActionButtonText: 'Discard'); - if (shouldContinue) { - await tester.tap(find.byWidget(actionButton)); - } else { - await tester.tap(find.byWidget(cancelButton)); - } + shouldContinue: shouldContinue); } // Test the "Discard…?" confirmation dialog when you tap "Edit message" in @@ -1624,8 +1881,8 @@ void main() { await enterContent(tester, 'composing new message'); // Expect confirmation dialog; tap Cancel - await startInteractionFromActionSheet(tester, messageId: messageId); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await startEditInteractionFromActionSheet(tester, messageId: messageId); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); check(connection.takeRequests()).isEmpty(); // fetch-raw-content request wasn't actually sent; // take back its prepared response @@ -1638,9 +1895,9 @@ void main() { checkContentInputValue(tester, 'composing new message…'); // Try again, but this time tap Discard and expect to enter an edit session - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request @@ -1672,7 +1929,7 @@ void main() { final messageId = msgIdInNarrow(narrow); await prepareEditMessage(tester, narrow: narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, 'bar'); @@ -1689,7 +1946,7 @@ void main() { // Expect confirmation dialog; tap Cancel await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); checkNotInEditingMode(tester, narrow: narrow, expectedContentText: 'composing new message'); @@ -1699,7 +1956,7 @@ void main() { // Try again, but this time tap Discard and expect to enter edit session await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); checkContentInputValue(tester, 'bar'); await enterContent(tester, 'baz'); @@ -1729,7 +1986,7 @@ void main() { checkNotInEditingMode(tester, narrow: narrow); final messageId = msgIdInNarrow(narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo', fetchShouldSucceed: false); @@ -1777,7 +2034,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, delay: Duration(seconds: 5)); await checkAwaitingRawMessageContent(tester); await tester.pump(duringFetchRawContentRequest! @@ -1796,7 +2053,7 @@ void main() { // We've canceled the previous edit session, so we should be able to // do a new edit-message session… - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request diff --git a/test/widgets/content_checks.dart b/test/widgets/content_checks.dart deleted file mode 100644 index 1faf0e2d62..0000000000 --- a/test/widgets/content_checks.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; - -import 'package:zulip/widgets/content.dart'; - -extension RealmContentNetworkImageChecks on Subject { - Subject get src => has((i) => i.src, 'src'); - // TODO others -} - -extension AvatarImageChecks on Subject { - Subject get userId => has((i) => i.userId, 'userId'); -} - -extension AvatarShapeChecks on Subject { - Subject get size => has((i) => i.size, 'size'); - Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); - Subject get child => has((i) => i.child, 'child'); -} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index a788225aac..2b7eb45180 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -7,12 +7,15 @@ 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/core.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/content.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/katex.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; @@ -24,12 +27,10 @@ import '../model/binding.dart'; import '../model/content_test.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; /// Simulate a nested "inner" span's style by merging all ancestor-span @@ -106,6 +107,44 @@ TextStyle? mergedStyleOf(WidgetTester tester, Pattern spanPattern, { /// and reports the target's font size. typedef TargetFontSizeFinder = double Function(InlineSpan rootSpan); +Widget plainContent(String html) { + return Builder(builder: (context) => + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: parseContent(html).nodes))); +} + +// TODO(#488) For content that we need to show outside a per-message context +// or a context without a full PerAccountStore, make sure to include tests +// that don't provide such context. +Future prepareContent(WidgetTester tester, Widget child, { + List navObservers = const [], + bool wrapWithPerAccountStoreWidget = false, + InitialSnapshot? initialSnapshot, +}) async { + if (wrapWithPerAccountStoreWidget) { + initialSnapshot ??= eg.initialSnapshot(); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + } else { + assert(initialSnapshot == null); + } + + addTearDown(testBinding.reset); + + prepareBoringImageHttpClient(); + + await tester.pumpWidget(TestZulipApp( + accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, + navigatorObservers: navObservers, + child: child)); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } + + debugNetworkImageHttpClientProvider = null; +} + void main() { // For testing a new content feature: // @@ -120,45 +159,11 @@ void main() { TestZulipBinding.ensureInitialized(); - Widget plainContent(String html) { - return Builder(builder: (context) => - DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: parseContent(html).nodes))); - } - Widget messageContent(String html) { return MessageContent(message: eg.streamMessage(content: html), content: parseContent(html)); } - // TODO(#488) For content that we need to show outside a per-message context - // or a context without a full PerAccountStore, make sure to include tests - // that don't provide such context. - Future prepareContent(WidgetTester tester, Widget child, { - List navObservers = const [], - bool wrapWithPerAccountStoreWidget = false, - }) async { - if (wrapWithPerAccountStoreWidget) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - } - - addTearDown(testBinding.reset); - - prepareBoringImageHttpClient(); - - await tester.pumpWidget(TestZulipApp( - accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, - navigatorObservers: navObservers, - child: child)); - await tester.pump(); // global store - if (wrapWithPerAccountStoreWidget) { - await tester.pump(); - } - - debugNetworkImageHttpClientProvider = null; - } - /// Test that the given content example renders without throwing an exception. /// /// This requires [ContentExample.expectedText] to be non-null in order to @@ -555,9 +560,17 @@ void main() { }); group('MathBlock', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math block. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathBlock); - testWidgets('displays KaTeX source; experimental flag default', (tester) async { + testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); tester.widget(find.text(r'\lambda', findRichText: true)); }); @@ -571,95 +584,6 @@ void main() { await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); tester.widget(find.text('λ', findRichText: true)); }); - - void checkKatexText( - WidgetTester tester, - String text, { - required String fontFamily, - required double fontSize, - required double fontHeight, - }) { - check(mergedStyleOf(tester, text)).isNotNull() - ..fontFamily.equals(fontFamily) - ..fontSize.equals(fontSize); - check(tester.getSize(find.text(text))) - .height.isCloseTo(fontSize * fontHeight, 0.5); - } - - testWidgets('displays KaTeX content with different sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; - final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (final katexNode in nodes) { - final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, katexNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - } - }); - - testWidgets('displays KaTeX content with nested sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexNestedSizing; - await prepareContent(tester, plainContent(content.html)); - - var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, '1', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - - fontSize = 4.976 * fontSize; - checkKatexText(tester, '2', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - }); - - testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexDelimSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; - var nodes = baseNode.nodes!.skip(1); // Skip .strut node. - - final fontSize = kBaseKatexTextStyle.fontSize!; - - final firstNode = nodes.first; - checkKatexText(tester, firstNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - nodes = nodes.skip(1); - - for (var katexNode in nodes) { - katexNode = katexNode.nodes!.single; // Skip empty .mord parent. - final fontFamily = katexNode.styles.fontFamily!; - checkKatexText(tester, katexNode.text!, - fontFamily: fontFamily, - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - } - }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -680,10 +604,12 @@ void main() { Future checkFontSizeRatio(WidgetTester tester, { required String targetHtml, required TargetFontSizeFinder targetFontSizeFinder, + bool wrapWithPerAccountStoreWidget = false, }) async { - await prepareContent(tester, plainContent( - '

header-plain $targetHtml

\n' - '

paragraph-plain $targetHtml

')); + await prepareContent(tester, wrapWithPerAccountStoreWidget: wrapWithPerAccountStoreWidget, + plainContent( + '

header-plain $targetHtml

\n' + '

paragraph-plain $targetHtml

')); final headerRootSpan = tester.renderObject(find.textContaining('header')).text; final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain '); @@ -1032,6 +958,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, @@ -1065,9 +993,56 @@ void main() { }); group('inline math', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math span. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isTrue(); + + const html = '' + 'λ' + ' \\lambda ' + ''; + await checkFontSizeRatio(tester, + targetHtml: html, + targetFontSizeFinder: (rootSpan) { + late final double result; + rootSpan.visitChildren((span) { + if (span case WidgetSpan(child: KatexWidget() && var widget)) { + result = mergedStyleOf(tester, + findAncestor: find.byWidget(widget), r'λ')!.fontSize!; + return false; + } + return true; + }); + return result; + }); + }); + + testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { + const html = '' + 'λ' + ' \\lambda ' + ''; + await checkFontSizeRatio(tester, + targetHtml: html, + targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'λ')); + }, skip: true // TODO(#46): adapt this test + // (it needs a more complex targetFontSizeFinder; + // see other uses in this file for examples.) + ); + + testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + const html = '' 'λ' ' \\lambda ' @@ -1077,7 +1052,11 @@ void main() { targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda')); }); - testWidgets('displays KaTeX source; experimental flag default', (tester) async { + testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + await prepareContent(tester, plainContent(ContentExample.mathInline.html)); tester.widget(find.text(r'\lambda', findRichText: true)); }); @@ -1100,16 +1079,52 @@ void main() { // the timezone of the environment running these tests. Accept here a wide // range of times. See comments in "show dates" test in // `test/widgets/message_list_test.dart`. - final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d(?: [AP]M)?$'); + final renderedTextRegexpTwelveHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexpTwentyFourHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d$'); + + Future prepare( + WidgetTester tester, + [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault] + ) async { + final initialSnapshot = eg.initialSnapshot() + ..userSettings.twentyFourHourTime = twentyFourHourTimeMode; + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + initialSnapshot: initialSnapshot, + plainContent('

$timeSpanHtml

')); + } testWidgets('smoke', (tester) async { - await prepareContent(tester, plainContent('

$timeSpanHtml

')); + await prepare(tester); tester.widget(find.textContaining(renderedTextRegexp)); }); + testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twelveHour); + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twentyFourHour); + check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.localeDefault); + // This expectation holds as long as we're always formatting in en_US, + // the default locale, which uses the twelve-hour format. + // TODO(#1727) follow the actual locale; test with different locales + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + void testIconAndTextSameColor(String description, String html) { testWidgets('clock icon and text are the same color: $description', (tester) async { - await prepareContent(tester, plainContent(html)); + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + plainContent(html)); final icon = tester.widget( find.descendant(of: find.byType(GlobalTime), @@ -1129,6 +1144,8 @@ void main() { group('maintains font-size ratio with surrounding text', () { Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async { await checkFontSizeRatio(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, targetHtml: '', targetFontSizeFinder: (rootSpan) { late final double result; @@ -1161,6 +1178,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), @@ -1292,69 +1329,6 @@ void main() { }); }); - group('AvatarImage', () { - late PerAccountStore store; - - Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final user = eg.user(avatarUrl: avatarUrl); - await store.addUser(user); - - prepareBoringImageHttpClient(); - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: AvatarImage(userId: user.userId, size: size ?? 30)))); - await tester.pump(); - await tester.pump(); - tester.widget(find.byType(AvatarImage)); - final widgets = tester.widgetList( - find.byType(RealmContentNetworkImage)); - return widgets.firstOrNull?.src; - } - - testWidgets('smoke with absolute URL', (tester) async { - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl)).isNotNull() - .asString.equals(avatarUrl); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with relative URL', (tester) async { - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl)) - .equals(store.tryResolveUrl(avatarUrl)!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('absolute URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)).isNotNull() - .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('relative URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)) - .equals(store.tryResolveUrl('/avatar-medium.png')!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with invalid URL', (tester) async { - const avatarUrl = '::not a URL::'; - check(await actualUrl(tester, avatarUrl)).isNull(); - debugNetworkImageHttpClientProvider = null; - }); - }); - group('MessageTable', () { testFontWeight('bold column header label', // | a | b | c | d | 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 de3ad7227c..5948e6828c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -13,7 +13,6 @@ import 'package:legacy_checks/legacy_checks.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/realm.dart'; -import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; @@ -29,6 +28,7 @@ import '../model/emoji_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; +import 'checks.dart'; import 'content_test.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -36,6 +36,7 @@ import 'text_test.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -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 { @@ -308,7 +330,8 @@ void main() { 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; @@ -559,7 +582,3 @@ void main() { }); }); } - -extension EmojiPickerListItemChecks on Subject { - Subject get emoji => has((x) => x.emoji, 'emoji'); -} diff --git a/test/widgets/finders.dart b/test/widgets/finders.dart new file mode 100644 index 0000000000..fe4111cdbe --- /dev/null +++ b/test/widgets/finders.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Like `find.text` from flutter_test upstream, but with +/// the `includePlaceholders` option. +/// +/// When `includePlaceholders` is true, any [PlaceholderSpan] (for example, +/// any [WidgetSpan]) in the tree will be represented as +/// an "object replacement character", U+FFFC. +/// When `includePlaceholders` is false, such spans will be omitted. +/// +/// TODO(upstream): get `find.text` to accept includePlaceholders +Finder findText(String text, { + bool findRichText = false, + bool includePlaceholders = true, + bool skipOffstage = true, +}) { + return _TextWidgetFinder(text, + findRichText: findRichText, + includePlaceholders: includePlaceholders, + skipOffstage: skipOffstage); +} + +// (Compare the implementation in `package:flutter_test/src/finders.dart`.) +abstract class _MatchTextFinder extends MatchFinder { + _MatchTextFinder({this.findRichText = false, this.includePlaceholders = true, + super.skipOffstage}); + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + final bool findRichText; + + final bool includePlaceholders; + + bool matchesText(String textToMatch); + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + if (widget is EditableText) { + return _matchesEditableText(widget); + } + + if (!findRichText) { + return _matchesNonRichText(widget); + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget); + } + + bool _matchesRichText(Widget widget) { + if (widget is RichText) { + return matchesText(widget.text.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesNonRichText(Widget widget) { + if (widget is Text) { + if (widget.data != null) { + return matchesText(widget.data!); + } + assert(widget.textSpan != null); + return matchesText(widget.textSpan!.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesEditableText(EditableText widget) { + return matchesText(widget.controller.text); + } +} + +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder(this.text, {super.findRichText, super.includePlaceholders, + super.skipOffstage}); + + final String text; + + @override + String get description => 'text "$text"'; + + @override + bool matchesText(String textToMatch) { + return textToMatch == text; + } +} diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 3c8db1dfcf..bf207155b7 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -24,8 +24,7 @@ import '../model/binding.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main () { @@ -34,10 +33,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 +59,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 +124,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 +132,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 +243,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 +328,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 +390,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 +410,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 +428,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 +450,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 +461,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 +478,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 +499,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 c91b70cb44..fe96b427ab 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -9,6 +9,7 @@ import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; @@ -58,6 +59,7 @@ void main() { List? subscriptions, List? users, required List unreadMessages, + List? otherMessages, NavigatorObserver? navigatorObserver, }) async { addTearDown(testBinding.reset); @@ -196,6 +198,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. @@ -226,7 +229,7 @@ void main() { streams: [stream], subscriptions: [subscription], unreadMessages: [eg.streamMessage(stream: stream, topic: 'lunch')]); - await store.addUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.muted); await tester.pump(); check(tester.widgetList(find.text('lunch'))).length.equals(0); }); @@ -248,7 +251,7 @@ void main() { streams: [stream], subscriptions: [subscription], unreadMessages: [eg.streamMessage(stream: stream, topic: 'lunch')]); - await store.addUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.unmuted); await tester.pump(); check(tester.widgetList(find.text('lunch'))).length.equals(1); }); @@ -326,7 +329,7 @@ void main() { streams: [channel], subscriptions: [eg.subscription(channel)], unreadMessages: [message]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), @@ -339,7 +342,7 @@ void main() { subscriptions: [eg.subscription(channel)], unreadMessages: [eg.streamMessage(stream: channel, topic: topic, flags: [MessageFlag.mentioned])]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), @@ -355,12 +358,45 @@ void main() { streams: [channel], subscriptions: [eg.subscription(channel, isMuted: true)], unreadMessages: [message]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.unmuted); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), icon: ZulipIcons.unmute)).isTrue(); }); + + testWidgets('unmuted (topics treated case-insensitively)', (tester) async { + // Case-insensitivity of both topic-visibility and unreads data + // TODO(#1065) this belongs in test/model/ once the inbox page has + // its own view-model + + final message1 = eg.streamMessage(stream: channel, topic: 'aaa'); + final message2 = eg.streamMessage(stream: channel, topic: 'AaA', flags: [MessageFlag.read]); + final message3 = eg.streamMessage(stream: channel, topic: 'aAa', flags: [MessageFlag.read]); + await setupPage(tester, + users: [eg.selfUser, eg.otherUser], + streams: [channel], + subscriptions: [eg.subscription(channel, isMuted: true)], + unreadMessages: [message1]); + await store.setUserTopic(channel, 'aaa', UserTopicVisibilityPolicy.unmuted); + await tester.pump(); + + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '1'))).findsOne(); + + await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message2])); + await tester.pump(); + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '2'))).findsOne(); + + await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message3])); + await tester.pump(); + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '3'))).findsOne(); + }); }); group('collapsing', () { diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart new file mode 100644 index 0000000000..325bcf0b6a --- /dev/null +++ b/test/widgets/katex_test.dart @@ -0,0 +1,153 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/katex.dart'; + +import '../model/binding.dart'; +import '../model/katex_test.dart'; +import '../model/store_checks.dart'; +import 'content_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('MathBlock', () { + group('characters render at specific offsets with specific size', () { + final testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ + (KatexExample.mathBlockKatexSizing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), + ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), + ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), + ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), + ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), + ]), + (KatexExample.mathBlockKatexNestedSizing, skip: false, [ + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + (KatexExample.mathBlockKatexDelimSizing, skip: false, [ + ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), + ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), + ]), + (KatexExample.mathBlockKatexSpace, skip: false, [ + ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), + (':', Offset(16.00, 2.24), Size(5.72, 25.00)), + ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexSuperscript, skip: false, [ + ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), + ]), + (KatexExample.mathBlockKatexSubscript, skip: false, [ + ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), + ]), + (KatexExample.mathBlockKatexSubSuperScript, skip: false, [ + ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), + ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), + ]), + (KatexExample.mathBlockKatexRaisebox, skip: false, [ + ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), + ]), + (KatexExample.mathBlockKatexNegativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), + (KatexExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(KatexWidget)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedTopLeftOffset = characterData.$2; + final expectedSize = characterData.$3; + + final rect = tester.getRect(find.text(character)); + final topLeftOffset = rect.topLeft - baseRect.topLeft; + final size = rect.size; + + check(topLeftOffset) + .within(distance: 0.05, from: expectedTopLeftOffset); + check(size) + .within(distance: 0.05, from: expectedSize); + } + }, skip: testCase.skip); + } + }); + }); +} + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +} diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index fda7122123..a7f6240852 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -6,9 +6,9 @@ import 'package:clock/clock.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; 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:video_player_platform_interface/video_player_platform_interface.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'; @@ -17,6 +17,7 @@ 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 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -31,16 +32,14 @@ const kTestVideoUrl = "https://a/video.mp4"; const kTestUnsupportedVideoUrl = "https://a/unsupported.mp4"; const kTestVideoDuration = Duration(seconds: 10); -class FakeVideoPlayerPlatform extends Fake - with MockPlatformInterfaceMixin - implements VideoPlayerPlatform { +class FakeVideoPlayerPlatform extends VideoPlayerPlatform { static final FakeVideoPlayerPlatform instance = FakeVideoPlayerPlatform(); static void registerWith() { VideoPlayerPlatform.instance = instance; } - static const int _kTextureId = 0xffffffff; + static const int _kPlayerId = 0xffffffff; StreamController _streamController = StreamController(); bool _hasError = false; @@ -103,21 +102,21 @@ class FakeVideoPlayerPlatform extends Fake Future init() async {} @override - Future dispose(int textureId) async { + Future dispose(int playerId) async { if (_hasError) { assert(!initialized); - assert(textureId == VideoPlayerController.kUninitializedTextureId); + assert(playerId == VideoPlayerController.kUninitializedPlayerId); return; } assert(initialized); - assert(textureId == _kTextureId); + assert(playerId == _kPlayerId); } @override - Future create(DataSource dataSource) async { + Future createWithOptions(VideoCreationOptions options) async { assert(!initialized); - if (dataSource.uri == kTestUnsupportedVideoUrl) { + if (options.dataSource.uri == kTestUnsupportedVideoUrl) { _hasError = true; _streamController.addError( PlatformException( @@ -134,24 +133,24 @@ class FakeVideoPlayerPlatform extends Fake size: const Size(100, 100), rotationCorrection: 0, )); - return _kTextureId; + return _kPlayerId; } @override - Stream videoEventsFor(int textureId) { - assert(textureId == _kTextureId); + Stream videoEventsFor(int playerId) { + assert(playerId == _kPlayerId); return _streamController.stream; } @override - Future setLooping(int textureId, bool looping) async { - assert(textureId == _kTextureId); + Future setLooping(int playerId, bool looping) async { + assert(playerId == _kPlayerId); assert(!looping); } @override - Future play(int textureId) async { - assert(textureId == _kTextureId); + Future play(int playerId) async { + assert(playerId == _kPlayerId); _stopwatch?.start(); _streamController.add(VideoEvent( eventType: VideoEventType.isPlayingStateUpdate, @@ -160,8 +159,8 @@ class FakeVideoPlayerPlatform extends Fake } @override - Future pause(int textureId) async { - assert(textureId == _kTextureId); + Future pause(int playerId) async { + assert(playerId == _kPlayerId); _stopwatch?.stop(); _streamController.add(VideoEvent( eventType: VideoEventType.isPlayingStateUpdate, @@ -170,39 +169,42 @@ class FakeVideoPlayerPlatform extends Fake } @override - Future setVolume(int textureId, double volume) async { - assert(textureId == _kTextureId); + Future setVolume(int playerId, double volume) async { + assert(playerId == _kPlayerId); } @override - Future seekTo(int textureId, Duration pos) async { + Future seekTo(int playerId, Duration pos) async { _callLog.add('seekTo'); - assert(textureId == _kTextureId); + assert(playerId == _kPlayerId); _lastSetPosition = pos >= kTestVideoDuration ? kTestVideoDuration : pos; _stopwatch?.reset(); } @override - Future setPlaybackSpeed(int textureId, double speed) async { - assert(textureId == _kTextureId); + Future setPlaybackSpeed(int playerId, double speed) async { + assert(playerId == _kPlayerId); } @override - Future getPosition(int textureId) async { - assert(textureId == _kTextureId); + Future getPosition(int playerId) async { + assert(playerId == _kPlayerId); return position; } @override - Widget buildView(int textureId) { - assert(textureId == _kTextureId); + Widget buildViewWithOptions(VideoViewOptions options) { + assert(options.playerId == _kPlayerId); return const SizedBox(width: 100, height: 100); } } void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; + + late PerAccountStore store; group('LightboxHero', () { late PerAccountStore store; @@ -273,6 +275,7 @@ void main() { }); testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/930 Rect getElementRect(Element element) => tester.getRect(find.byElementPredicate((e) => e == element)); @@ -308,7 +311,13 @@ void main() { } debugNetworkImageHttpClientProvider = null; - }); + }, skip: true, // TODO get this no-hero test to work again with new page transitions; + // see https://github.com/flutter/flutter/pull/165832#issuecomment-3111641360 . + // Perhaps specify the old default, of ZoomPageTransitionsBuilder? + // Or make getElementRect work relative to the enclosing page, + // rather than the whole screen, so that the test becomes robust to + // the whole pages moving around. + ); }); group('_ImageLightboxPage', () { @@ -316,10 +325,16 @@ void main() { 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; @@ -351,20 +366,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 11:12:24 PM'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: sender.userId, fullName: 'New name')); + await tester.pump(); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 11:12:24 PM'); + + 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 11:12:24 PM'); debugNetworkImageHttpClientProvider = null; }); diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index a5109ba5db..1ff971072c 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -23,7 +23,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; void main() { TestZulipBinding.ensureInitialized(); diff --git a/test/widgets/message_list_checks.dart b/test/widgets/message_list_checks.dart deleted file mode 100644 index 6ce43a2d43..0000000000 --- a/test/widgets/message_list_checks.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/model/narrow.dart'; -import 'package:zulip/widgets/message_list.dart'; - -extension MessageListPageChecks on Subject { - Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); -} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index df05b4f0cc..3636861a2e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -11,13 +13,17 @@ 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/basic.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'; @@ -27,6 +33,9 @@ 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 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -37,15 +46,13 @@ 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 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -55,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, @@ -79,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, @@ -112,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, @@ -138,6 +158,20 @@ void main() { check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); + testWidgets('narrow gets normalized from "general chat"', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1717 + final stream = eg.stream(); + // Open the page on a topic with the literal name "general chat". + final topic = eg.defaultRealmEmptyTopicDisplayName; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + await setupMessageListPage(tester, narrow: topicNarrow, + streams: [stream], + messages: [eg.streamMessage(stream: stream, topic: topic, content: "

a message

")]); + final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + // The page's narrow has been updated; the topic is "", not "general chat". + check(state.narrow).equals(eg.topicNarrow(stream.streamId, '')); + }); + testWidgets('composeBoxState finds compose box', (tester) async { final stream = eg.stream(); await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), @@ -208,6 +242,36 @@ void main() { channel.name, eg.defaultRealmEmptyTopicDisplayName); }); + void testChannelIconInChannelRow(IconData expectedIcon, { + required bool isWebPublic, + required bool inviteOnly, + }) { + final description = 'channel icon in channel row; ' + 'web-public: $isWebPublic, invite-only: $inviteOnly'; + testWidgets(description, (tester) async { + final color = 0xff95a5fd; + + final channel = eg.stream(isWebPublic: isWebPublic, inviteOnly: inviteOnly); + final subscription = eg.subscription(channel, color: color); + + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + subscriptions: [subscription], + messages: [eg.streamMessage(stream: channel)]); + + final iconElement = tester.element(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.byIcon(expectedIcon))); + + check(Theme.brightnessOf(iconElement)).equals(Brightness.light); + check(iconElement.widget as Icon).color.equals(Color(0xff5972fc)); + }); + } + testChannelIconInChannelRow(ZulipIcons.globe, isWebPublic: true, inviteOnly: false); + testChannelIconInChannelRow(ZulipIcons.lock, isWebPublic: false, inviteOnly: true); + testChannelIconInChannelRow(ZulipIcons.hash_sign, isWebPublic: false, inviteOnly: false); + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -228,6 +292,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.byIcon(ZulipIcons.topics)); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + testWidgets('show topic visibility policy for topic narrows', (tester) async { final channel = eg.stream(); const topic = 'topic'; @@ -243,6 +326,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.byIcon(ZulipIcons.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', () { @@ -282,20 +439,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'; @@ -330,9 +492,9 @@ 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', }); }); @@ -363,9 +525,9 @@ 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', }); }); @@ -373,6 +535,10 @@ void main() { }); 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; @@ -584,6 +750,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; @@ -620,6 +788,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( @@ -682,6 +907,30 @@ void main() { // Wait for the pending timers to end. await tester.pump(const Duration(seconds: 15)); }); + + testWidgets('muted user typing', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, users: users, messages: [streamMessage]); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.otherUser.userId), + expected: 'Other User is typing…'); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Other User and Third User are typing…'); + + await store.setMutedUsers([eg.otherUser.userId]); + await tester.pump(); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Third User is typing…', // no "Other User" + ); + + // Wait for the pending timers to end. + await tester.pump(const Duration(seconds: 15)); + }); }); group('MarkAsReadWidget', () { @@ -943,7 +1192,8 @@ void main() { connection.prepare(json: SendMessageResult(id: 1).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); - await tester.pump(); + await tester.pump(Duration.zero); + final localMessageId = store.outboxMessages.keys.single; check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -952,8 +1202,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 { @@ -1188,6 +1442,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() @@ -1269,6 +1550,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: [ @@ -1278,7 +1574,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!); }); }); @@ -1342,30 +1638,108 @@ void main() { }); }); - group('formatHeaderDate', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final now = DateTime.parse("2023-01-10 12:00"); - final testCases = [ - ("2023-01-10 12:00", zulipLocalizations.today), - ("2023-01-10 00:00", zulipLocalizations.today), - ("2023-01-10 23:59", zulipLocalizations.today), - ("2023-01-09 23:59", zulipLocalizations.yesterday), - ("2023-01-09 00:00", zulipLocalizations.yesterday), - ("2023-01-08 00:00", "Jan 8"), - ("2022-12-31 00:00", "Dec 31, 2022"), - // Future times - ("2023-01-10 19:00", zulipLocalizations.today), - ("2023-01-11 00:00", "Jan 11, 2023"), - ]; - for (final (dateTime, expected) in testCases) { - test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); - }); + group('MessageTimestampStyle', () { + void doTests( + MessageTimestampStyle style, + List<( + String timestampStr, + String? expectedTwelveHour, + String? expectedTwentyFourHour, + )> cases, { + DateTime? now, + }) { + now ??= DateTime.parse("2023-01-10 12:00"); + for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) { + for (final mode in TwentyFourHourTimeMode.values) { + final expected = switch (mode) { + TwentyFourHourTimeMode.twelveHour => expectedTwelveHour, + TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour, + // This expectation will hold as long as we're always using the + // default locale, en_US, which uses the twelve-hour format. + // TODO(#1727) test with other locales + TwentyFourHourTimeMode.localeDefault => expectedTwelveHour, + }; + + test('${style.name} in ${mode.name}: $timestampStr returns $expected', () { + addTearDown(testBinding.reset); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + withClock(Clock.fixed(now!), () { + final timestamp = DateTime.parse(timestampStr) + .millisecondsSinceEpoch ~/ 1000; + final result = style.format( + timestamp, + now: testBinding.utcNow().toLocal(), + twentyFourHourTimeMode: mode, + zulipLocalizations: zulipLocalizations); + check(result).equals(expected); + }); + }); + } + } + } + + for (final style in MessageTimestampStyle.values) { + switch (style) { + case MessageTimestampStyle.none: + doTests(style, [('2023-01-10 12:00', null, null)]); + case MessageTimestampStyle.dateOnlyRelative: + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + doTests(style, + now: DateTime.parse("2023-01-10 12:00"), + [ + ("2023-01-10 12:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 00:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 23:59", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-09 23:59", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-09 00:00", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-08 00:00", "Jan 8", "Jan 8"), + ("2022-12-31 00:00", "Dec 31, 2022", "Dec 31, 2022"), + // Future times + ("2023-01-10 19:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-11 00:00", "Jan 11, 2023", "Jan 11, 2023"), + ]); + case MessageTimestampStyle.timeOnly: + doTests(style, [('2023-01-10 12:00', '12:00 PM', '12:00')]); + case MessageTimestampStyle.lightbox: + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00:00 PM', + 'Jan 10, 2023 12:00:00')]); + case MessageTimestampStyle.full: + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00 PM', + 'Jan 10, 2023 12:00')]); + } } }); 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); @@ -1387,15 +1761,18 @@ void main() { } } + final user = eg.user(); + Future handleNewAvatarEventAndPump(WidgetTester tester, String avatarUrl) async { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, avatarUrl: avatarUrl)); + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, avatarUrl: avatarUrl)); await tester.pump(); } prepareBoringImageHttpClient(); - await setupMessageListPage(tester, messageCount: 10); - checkResultForSender(eg.selfUser.avatarUrl); + await setupMessageListPage(tester, users: [user], + messages: [eg.streamMessage(sender: user)]); + checkResultForSender(user.avatarUrl); await handleNewAvatarEventAndPump(tester, '/foo.png'); checkResultForSender('/foo.png'); @@ -1448,6 +1825,379 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.textContaining('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + + 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', () { diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart new file mode 100644 index 0000000000..a95d42dc1a --- /dev/null +++ b/test/widgets/new_dm_sheet_test.dart @@ -0,0 +1,440 @@ +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/basic.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/user.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 'finders.dart'; +import 'test_app.dart'; + +late PerAccountStore store; + +Future setupSheet(WidgetTester tester, { + required List users, + List? mutedUserIds, +}) async { + addTearDown(testBinding.reset); + + Route? lastPushedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => lastPushedRoute = route; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } + + 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.ancestor(of: findText(user.fullName, includePlaceholders: false), + matching: find.byType(InkWell)).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 full list initially', (tester) async { + await setupSheet(tester, users: testUsers); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); + }); + + testWidgets('shows filtered users based on search', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + }); + + testWidgets('deactivated users excluded', (tester) async { + // Omit a deactivated user both before there's a query… + final deactivatedUser = eg.user(fullName: 'Impostor Charlie', isActive: false); + await setupSheet(tester, users: [...testUsers, deactivatedUser]); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + + // … and after a query that would match their name. + await tester.enterText(find.byType(TextField), 'Charlie'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(1); + }); + + testWidgets('muted users excluded', (tester) async { + // Omit muted users both before there's a query… + final mutedUser = eg.user(fullName: 'Someone Muted'); + await setupSheet(tester, + users: [...testUsers, mutedUser], mutedUserIds: [mutedUser.userId]); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + + // … and after a query. One which matches both the user's actual name and + // the replacement text "Muted user", for good measure. + await tester.enterText(find.byType(TextField), 'e'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(2); + }); + + // 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(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pump(); + check(findText(includePlaceholders: false, '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(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'Anderson'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'son'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, '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(findText(includePlaceholders: false, 'No users found')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, '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(findText(includePlaceholders: false, 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(findText(includePlaceholders: false, 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('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsOne(); + check(find.textContaining('Busy')).findsNothing(); + }); + }); + + 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/page_checks.dart b/test/widgets/page_checks.dart deleted file mode 100644 index a3692273bf..0000000000 --- a/test/widgets/page_checks.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/page.dart'; - -extension WidgetRouteChecks on Subject> { - Subject get page => has((x) => x.page, 'page'); -} - -extension AccountRouteChecks on Subject> { - Subject get accountId => has((x) => x.accountId, 'accountId'); -} 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_page_checks.dart b/test/widgets/profile_page_checks.dart deleted file mode 100644 index bc08b43ec1..0000000000 --- a/test/widgets/profile_page_checks.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/profile.dart'; - -extension ProfilePageChecks on Subject { - Subject get userId => has((x) => x.userId, 'userId'); -} diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 30f6433528..f1aaebd807 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,43 +1,65 @@ +import 'dart:io'; + 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:url_launcher/url_launcher.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/button.dart'; 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/remote_settings.dart'; import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/user.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_images.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; -import 'profile_page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; +late PerAccountStore store; +late FakeApiConnection connection; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, + bool realmPresenceDisabled = false, NavigatorObserver? navigatorObserver, }) async { addTearDown(testBinding.reset); final initialSnapshot = eg.initialSnapshot( customProfileFields: customProfileFields, - realmDefaultExternalAccounts: realmDefaultExternalAccounts); + realmDefaultExternalAccounts: realmDefaultExternalAccounts, + realmPresenceDisabled: realmPresenceDisabled); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; 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, @@ -48,39 +70,54 @@ Future setupPage(WidgetTester tester, { await tester.pumpAndSettle(); } -CustomProfileField mkCustomProfileField( - int id, - CustomProfileFieldType type, { - int? order, - bool? displayInProfileSummary, - String? fieldData, -}) { - return CustomProfileField( - id: id, - type: type, - order: order ?? id, - name: 'field$id', - hint: 'hint$id', - fieldData: fieldData ?? '', - displayInProfileSummary: displayInProfileSummary ?? true, - ); -} - void main() { TestZulipBinding.ensureInitialized(); - group('ProfilePage', () { - testWidgets('page builds; profile page renders', (tester) async { - final user = eg.user(userId: 1, fullName: 'test user', - deliveryEmail: 'testuser@example.com'); + testWidgets('page builds; profile page renders', (tester) async { + final user = eg.user(userId: 1, fullName: 'test user', + deliveryEmail: 'testuser@example.com'); - await setupPage(tester, users: [user], pageUserId: user.userId); + await setupPage(tester, users: [user], pageUserId: user.userId); - check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); - check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); - check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); - }); + check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); + check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + // Tests for user status are in their own test group. + check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); + }); + testWidgets('page builds; error page shows up if data is missing', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); + check(because: 'find no user avatar', find.byType(Avatar).evaluate()).isEmpty(); + check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; dm links to correct narrow', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, + users: [eg.user(userId: 1)], + pageUserId: 1, + navigatorObserver: testNavObserver, + ); + + final targetWidget = find.byIcon(Icons.email); + await tester.ensureVisible(targetWidget); + await tester.tap(targetWidget); + check(pushedRoutes).last.isA().page + .isA() + .initNarrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + }); + + testWidgets('page builds; ensure long name does not overflow', (tester) async { + final longString = 'X' * 400; + final user = eg.user(userId: 1, fullName: longString); + await setupPage(tester, users: [user], pageUserId: user.userId); + check(find.text(longString).evaluate()).isNotEmpty(); + }); + + group('custom profile fields', () { testWidgets('page builds; profile page renders with profileData', (tester) async { await setupPage(tester, users: [ @@ -98,16 +135,16 @@ void main() { ], pageUserId: 1, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'), - mkCustomProfileField(3, CustomProfileFieldType.date), - mkCustomProfileField(4, CustomProfileFieldType.link), - mkCustomProfileField(5, CustomProfileFieldType.user), - mkCustomProfileField(6, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.date), + eg.customProfileField(4, CustomProfileFieldType.link), + eg.customProfileField(5, CustomProfileFieldType.user), + eg.customProfileField(6, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(7, CustomProfileFieldType.pronouns), + eg.customProfileField(7, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -143,12 +180,6 @@ void main() { .deepEquals([1, 2]); }); - testWidgets('page builds; error page shows up if data is missing', (tester) async { - await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); - check(because: 'find no user avatar', find.byType(Avatar).evaluate()).isEmpty(); - check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); - }); - testWidgets('page builds; link type will navigate', (tester) async { const testUrl = 'http://example/url'; final user = eg.user(userId: 1, profileData: { @@ -158,7 +189,7 @@ void main() { await setupPage(tester, users: [user], pageUserId: user.userId, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.link)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.link)], ); await tester.tap(find.text(testUrl)); @@ -177,7 +208,7 @@ void main() { users: [user], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.externalAccount, + eg.customProfileField(0, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}') ], realmDefaultExternalAccounts: { @@ -209,7 +240,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], navigatorObserver: testNavObserver, ); @@ -230,30 +261,48 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final textFinder = find.text('(unknown user)'); check(textFinder.evaluate()).length.equals(1); }); - testWidgets('page builds; dm links to correct narrow', (tester) async { - final pushedRoutes = >[]; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + 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: [eg.user(userId: 1)], + users: users, + mutedUserIds: [2], pageUserId: 1, - navigatorObserver: testNavObserver, - ); + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)]); - final targetWidget = find.byIcon(Icons.email); - await tester.ensureVisible(targetWidget); - await tester.tap(targetWidget); - check(pushedRoutes).last.isA().page - .isA() - .initNarrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + 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; user links render multiple avatars', (tester) async { @@ -268,7 +317,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final avatars = tester.widgetList(find.byType(Avatar)); @@ -276,13 +325,6 @@ void main() { .deepEquals([1, 2, 3]); }); - testWidgets('page builds; ensure long name does not overflow', (tester) async { - final longString = 'X' * 400; - final user = eg.user(userId: 1, fullName: longString); - await setupPage(tester, users: [user], pageUserId: user.userId); - check(find.text(longString).evaluate()).isNotEmpty(); - }); - testWidgets('page builds; ensure long customProfileFields do not overflow', (tester) async { final longString = 'X' * 400; final user = eg.user(userId: 1, fullName: 'fullName', profileData: { @@ -298,16 +340,16 @@ void main() { await setupPage(tester, users: [user, user2], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "$longString", "order": "1"}}'), // no [CustomProfileFieldType.date] because those can't be made long - mkCustomProfileField(3, CustomProfileFieldType.link), - mkCustomProfileField(4, CustomProfileFieldType.user), - mkCustomProfileField(5, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.link), + eg.customProfileField(4, CustomProfileFieldType.user), + eg.customProfileField(5, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(6, CustomProfileFieldType.pronouns), + eg.customProfileField(6, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -318,4 +360,319 @@ void main() { check(find.textContaining(longString).evaluate()).length.equals(7); }); }); + + group('user status', () { + testWidgets('non-self profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.otherUser], pageUserId: eg.otherUser.userId); + await store.changeUserStatus(eg.otherUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + + testWidgets('self-profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.selfUser], pageUserId: eg.selfUser.userId); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + }); + + group('invisible mode', () { + final findRow = find.widgetWithText(ZulipMenuItemButton, 'Invisible mode'); + final findToggle = find.descendant(of: findRow, matching: find.byType(Toggle)); + + void checkDoesNotAppear(WidgetTester tester) { + check(findRow).findsNothing(); + check(findToggle).findsNothing(); + } + + void checkAppears(WidgetTester tester) { + check(findRow).findsOne(); + check(findToggle).findsOne(); + } + + bool getValue(WidgetTester tester) => tester.widget(findToggle).value; + + void checkAppearsActive(WidgetTester tester, bool expected) { + check(getValue(tester)).equals(expected); + + check(tester.semantics.find(findRow)).matchesSemantics( + label: 'Invisible mode', + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasToggledState: true, + isToggled: expected); + } + + void prepareRequestSuccess([Duration delay = Duration.zero]) { + connection.prepare(json: {}, delay: delay); + } + + void prepareRequestError([Duration delay = Duration.zero]) { + connection.prepare(httpException: SocketException('failed'), delay: delay); + } + + void scheduleEventAfter(Duration duration, bool newInvisibleModeValue) async { + await Future.delayed(duration); + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: !newInvisibleModeValue)); + } + + void checkRequest(bool requestedInvisibleModeValue) { + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/settings') + ..bodyFields.deepEquals({ + 'presence_enabled': requestedInvisibleModeValue ? 'false' : 'true', + }); + } + + final toggleInteractionModeVariant = ValueVariant<_InvisibleModeToggleInteractionMode>( + _InvisibleModeToggleInteractionMode.values.toSet()); + + Future doToggle(WidgetTester tester, _InvisibleModeToggleInteractionMode mode) async { + switch (mode) { + case _InvisibleModeToggleInteractionMode.tapRow: + await tester.tap(findRow); + case _InvisibleModeToggleInteractionMode.tapToggle: + await tester.tap(findToggle); + case _InvisibleModeToggleInteractionMode.dragToggleThumb: + final textDirection = Directionality.of(tester.element(findToggle)); + final dragDx = switch ((getValue(tester), textDirection)) { + (true, TextDirection.ltr) => -40.0, + (false, TextDirection.ltr) => 40.0, + (true, TextDirection.rtl) => 40.0, + (false, TextDirection.rtl) => -40.0, + }; + await tester.drag(findToggle, Offset(dragDx, 0.0)); + } + } + + testWidgets('self-profile: appears', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId); + checkAppears(tester); + }); + + testWidgets('self-profile, but presence disabled in realm: does not appear', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, realmPresenceDisabled: true); + checkDoesNotAppear(tester); + }); + + testWidgets('non-self profile: does not appear', (tester) async { + await setupPage(tester, pageUserId: eg.otherUser.userId, users: [eg.otherUser]); + checkDoesNotAppear(tester); + }); + + testWidgets('without recent interaction, event causes immediate update, which sticks', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: false)); + await tester.pump(); + checkAppearsActive(tester, true); + + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + checkAppearsActive(tester, true); + }); + + testWidgets('smoke, turn on', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), true); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + + testWidgets('smoke, turn off', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: false)); + await tester.pump(); + checkAppearsActive(tester, true); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), false); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, false); + checkRequest(false); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, false); + }, variant: toggleInteractionModeVariant); + + testWidgets('event arrives after local-echo timeout', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(seconds: 10), true); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // Local-echo timeout passes and event hasn't come; change back. + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout); + await tester.pump(); + checkAppearsActive(tester, false); + + // The event comes after a while; update for the new value. + await tester.pump(Duration(seconds: 10)); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + + testWidgets('request has an error', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + final requestDuration = Duration(milliseconds: 100); + prepareRequestError(requestDuration); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // The appearance doesn't change as soon as the request errors, + // if it errored quickly… + await tester.pump(requestDuration); + checkAppearsActive(tester, true); + + // Try waiting a bit longer; it still hasn't changed… + // (https://github.com/zulip/zulip-flutter/pull/1631#discussion_r2191301085 ) + final epsilon = Duration(milliseconds: 50); + await tester.pump(epsilon); + checkAppearsActive(tester, true); + + // …it changes when [RemoteSettingBuilder.localEchoMinimum] + // has passed since the interaction. + await tester.pump( + RemoteSettingBuilder.localEchoMinimum - requestDuration - epsilon); + await tester.pump(); + checkAppearsActive(tester, false); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, false); + }, variant: toggleInteractionModeVariant); + + testWidgets('spam-tapping', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + Future doSpamTap({required bool expectedCurrentValue}) async { + checkAppearsActive(tester, expectedCurrentValue); + final newValue = !expectedCurrentValue; + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), newValue); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, newValue); + checkRequest(newValue); + } + + // Events will be coming in, but those don't control the switch; + // only the user interaction does, until there have been no interactions + // for [RemoteSettingBuilder.localEchoMinimum]. + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 90)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 30)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 60)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 300)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 45)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 600)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 5)); + await doSpamTap(expectedCurrentValue: false); + check(getValue(tester)).equals(true); + + await tester.pump(RemoteSettingBuilder.localEchoMinimum - Duration(milliseconds: 1)); + check(getValue(tester)).equals(true); + await tester.pump(Duration(milliseconds: 2)); + check(getValue(tester)).equals(true); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + }); +} + +enum _InvisibleModeToggleInteractionMode { + tapRow, + tapToggle, + dragToggleThumb, + // TODO(a11y) is there something separate to test? } diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 44322ccea1..a947420dad 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -1,52 +1,58 @@ 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'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; -import 'package:zulip/widgets/content.dart'; +import 'package:zulip/model/store.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'; +import 'package:zulip/widgets/user.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 'content_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; +import 'finders.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + User? selfUser, + List? mutedUserIds, NavigatorObserver? navigatorObserver, - String? newNameForSelfUser, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(selfAccount.id); - await store.addUser(eg.selfUser); + await store.addUser(selfUser); for (final user in users) { await store.addUser(user); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await store.addMessages(dmMessages); - if (newNameForSelfUser != null) { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, - fullName: newNameForSelfUser)); - } - await tester.pumpWidget(TestZulipApp( - accountId: eg.selfAccount.id, + accountId: selfAccount.id, navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], child: const HomePage())); @@ -56,17 +62,23 @@ 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(); } void main() { TestZulipBinding.ensureInitialized(); + Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( + (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, + ); + group('RecentDmConversationsPage', () { - Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( - (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); @@ -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', () { @@ -138,8 +176,9 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), - )); + // The title might contain a WidgetSpan (for status emoji); exclude + // the resulting placeholder character from the text to be matched. + matching: findText(expectedText, includePlaceholders: false))); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); check(renderObject.size.height).equals( @@ -148,8 +187,19 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final store = await testBinding.globalStore.perAccount( + testBinding.globalStore.accounts.single.id); await store.handleEvent(UpdateMessageFlagsAddEvent( id: 1, flag: MessageFlag.read, all: false, messages: [message.id])); await tester.pump(); @@ -178,21 +228,46 @@ void main() { }); testWidgets('short name takes one line', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Short name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 1); }); testWidgets('very long name takes two lines (must be ellipsized)', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -204,13 +279,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]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); testWidgets('no error when user somehow missing from user store', (tester) async { @@ -239,6 +328,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -258,15 +374,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]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); testWidgets('no error when one user somehow missing from user store', (tester) async { @@ -297,6 +443,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]); diff --git a/test/widgets/remote_settings_test.dart b/test/widgets/remote_settings_test.dart new file mode 100644 index 0000000000..11981dab1f --- /dev/null +++ b/test/widgets/remote_settings_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../model/binding.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('RemoteSettingBuilder', () { + // This builder widget is covered in the tests for the "Invisible mode" + // toggle switch in test/widgets/profile_test.dart. + }); +} diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 46df165ecc..e889fa0d3d 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -1,35 +1,70 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/settings.dart'; +import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/store_checks.dart'; import '../example_data.dart' as eg; +import '../test_navigation.dart'; +import 'checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + late TestNavigatorObserver testNavObserver; + late Route? lastPushedRoute; + late Route? lastPoppedRoute; + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + lastPushedRoute = null; + lastPoppedRoute = null; + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver], child: SettingsPage())); await tester.pump(); await tester.pump(); } - group('ThemeSetting', () { - Finder findRadioListTileWithTitle(String title) => find.ancestor( - of: find.text(title), - matching: find.byType(RadioListTile)); + void checkTileOnSettingsPage(WidgetTester tester, { + required String expectedTitle, + required String expectedSubtitle, + }) { + check(find.descendant(of: find.widgetWithText(ListTile, expectedTitle), + matching: find.text(expectedSubtitle))).findsOne(); + } + + Finder findRadioListTileWithTitle(String title) => find.ancestor( + of: find.text(title), + matching: find.byType(RadioListTile)); + + void checkRadioButtonAppearsChecked(WidgetTester tester, + String title, bool expectedIsChecked, {String? subtitle}) { + check(tester.semantics.find(findRadioListTileWithTitle(title))) + .containsSemantics( + label: subtitle == null + ? title + : '$title\n$subtitle', + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, isChecked: expectedIsChecked); + } + group('ThemeSetting', () { void checkThemeSetting(WidgetTester tester, { required ThemeSetting? expectedThemeSetting, }) { @@ -39,9 +74,7 @@ void main() { ThemeSetting.dark => 'Dark', }; for (final title in ['System', 'Light', 'Dark']) { - check(tester.widget>( - findRadioListTileWithTitle(title))) - .checked.equals(title == expectedCheckedTitle); + checkRadioButtonAppearsChecked(tester, title, title == expectedCheckedTitle); } check(testBinding.globalStore) .settings.themeSetting.equals(expectedThemeSetting); @@ -56,13 +89,13 @@ void main() { check(Theme.of(element)).brightness.equals(Brightness.light); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light); - await tester.tap(findRadioListTileWithTitle('Dark')); + await tester.tap(findRadioListTileWithTitle('Dark')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.dark); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.dark); - await tester.tap(findRadioListTileWithTitle('System')); + await tester.tap(findRadioListTileWithTitle('System')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.light); @@ -127,6 +160,141 @@ void main() { }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); + group('VisitFirstUnreadSetting', () { + String settingTitle(VisitFirstUnreadSetting setting) => switch (setting) { + VisitFirstUnreadSetting.always => 'First unread message', + VisitFirstUnreadSetting.conversations => 'First unread message in conversation views, newest message elsewhere', + VisitFirstUnreadSetting.never => 'Newest message', + }; + + void checkPage(WidgetTester tester, { + required VisitFirstUnreadSetting expectedSetting, + }) { + for (final setting in VisitFirstUnreadSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, setting == expectedSetting); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.conversations)); + + await tester.tap(find.text('Open message feeds at')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.never); + + await tester.tap(find.backButton()); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.never)); + }); + }); + + group('MarkReadOnScrollSetting', () { + String settingTitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => 'Always', + MarkReadOnScrollSetting.conversations => 'Only in conversation views', + MarkReadOnScrollSetting.never => 'Never', + }; + + String? settingSubtitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.', + MarkReadOnScrollSetting.never => null, + }; + + void checkPage(WidgetTester tester, { + required MarkReadOnScrollSetting expectedSetting, + }) { + for (final setting in MarkReadOnScrollSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, + setting == expectedSetting, + subtitle: settingSubtitle(setting)); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.conversations)); + + await tester.tap(find.text('Mark messages as read on scroll')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.never); + + await tester.tap(find.byType(BackButton)); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.never)); + }); + }); + // 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_checks.dart b/test/widgets/store_checks.dart deleted file mode 100644 index 9754654556..0000000000 --- a/test/widgets/store_checks.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/store.dart'; - -extension PerAccountStoreWidgetChecks on Subject { - Subject get accountId => has((x) => x.accountId, 'accountId'); - Subject get child => has((x) => x.child, 'child'); -} diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index f8da5e24a0..e2c9be2d18 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -24,10 +24,10 @@ class MyWidgetWithMixin extends StatefulWidget { const MyWidgetWithMixin({super.key}); @override - State createState() => MyWidgetWithMixinState(); + State createState() => _MyWidgetWithMixinState(); } -class MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { +class _MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { int anyDepChangeCounter = 0; int storeChangeCounter = 0; @@ -50,7 +50,7 @@ class MyWidgetWithMixinState extends State with PerAccountSto } } -extension MyWidgetWithMixinStateChecks on Subject { +extension _MyWidgetWithMixinStateChecks on Subject<_MyWidgetWithMixinState> { Subject get anyDepChangeCounter => has((w) => w.anyDepChangeCounter, 'anyDepChangeCounter'); Subject get storeChangeCounter => has((w) => w.storeChangeCounter, 'storeChangeCounter'); } @@ -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(BlankLoadingPlaceholder)).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(BlankLoadingPlaceholder)).findsNothing(); check(globalStore).identicalTo(testBinding.globalStore); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -84,6 +84,58 @@ 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(BlankLoadingPlaceholder)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes… + completer.complete(); + await tester.pump(); + await tester.pump(); // TODO why does GlobalStoreWidget need this extra frame? + // … mounts child instead of the loading page. + check(find.byType(BlankLoadingPlaceholder)).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(BlankLoadingPlaceholder)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes, even with an error… + completer.completeError(Exception('oops')); + await tester.pump(); + await tester.pump(); // TODO why does GlobalStoreWidget need this extra frame? + // … mounts child instead of the loading page. + check(find.byType(BlankLoadingPlaceholder)).findsNothing(); + check(find.text('done')).findsOne(); + }); + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { addTearDown(testBinding.reset); @@ -282,7 +334,7 @@ void main() { }); testWidgets('PerAccountStoreAwareStateMixin', (tester) async { - final widgetWithMixinKey = GlobalKey(); + final widgetWithMixinKey = GlobalKey<_MyWidgetWithMixinState>(); final accountId = eg.selfAccount.id; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); 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/topic_list_test.dart b/test/widgets/topic_list_test.dart new file mode 100644 index 0000000000..1cd9c4bb26 --- /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.setUserTopic( + 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.byIcon(ZulipIcons.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.byIcon(ZulipIcons.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/test/widgets/unread_count_badge_checks.dart b/test/widgets/unread_count_badge_checks.dart deleted file mode 100644 index dcd3f99d74..0000000000 --- a/test/widgets/unread_count_badge_checks.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/unread_count_badge.dart'; - -extension UnreadCountBadgeChecks on Subject { - Subject get count => has((b) => b.count, 'count'); - Subject get bold => has((b) => b.bold, 'bold'); - Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); -} diff --git a/test/widgets/user_test.dart b/test/widgets/user_test.dart new file mode 100644 index 0000000000..5078da0497 --- /dev/null +++ b/test/widgets/user_test.dart @@ -0,0 +1,82 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('AvatarImage', () { + late PerAccountStore store; + + Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final user = eg.user(avatarUrl: avatarUrl); + await store.addUser(user); + + prepareBoringImageHttpClient(); + await tester.pumpWidget(GlobalStoreWidget( + child: PerAccountStoreWidget(accountId: eg.selfAccount.id, + child: AvatarImage(userId: user.userId, size: size ?? 30)))); + await tester.pump(); + await tester.pump(); + tester.widget(find.byType(AvatarImage)); + final widgets = tester.widgetList( + find.byType(RealmContentNetworkImage)); + return widgets.firstOrNull?.src; + } + + testWidgets('smoke with absolute URL', (tester) async { + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl)).isNotNull() + .asString.equals(avatarUrl); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with relative URL', (tester) async { + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl)) + .equals(store.tryResolveUrl(avatarUrl)!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('absolute URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)).isNotNull() + .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('relative URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)) + .equals(store.tryResolveUrl('/avatar-medium.png')!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with invalid URL', (tester) async { + const avatarUrl = '::not a URL::'; + check(await actualUrl(tester, avatarUrl)).isNull(); + debugNetworkImageHttpClientProvider = null; + }); + }); +} 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..bb89052f24 --- /dev/null +++ b/tools/content/unimplemented_katex_test.dart @@ -0,0 +1,181 @@ +// 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 message = hardFailReason.message + ?? 'unknown reason at ${_inmostFrame(hardFailReason.stackTrace)}'; + final reason = 'hard fail: $message'; + (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) { + // Sort by number of failed messages descending, then by reason. + final r = - a.value.length.compareTo(b.value.length); + if (r != 0) return r; + return a.key.compareTo(b.key); + })) { + 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)); + } + }); +} + +/// The innermost frame of the given stack trace, +/// e.g. the line where an exception was thrown. +/// +/// Inevitably this is a bit heuristic, given the lack of any API guarantees +/// on the structure of [StackTrace]. +String _inmostFrame(StackTrace stackTrace) { + final firstLine = stackTrace.toString().split('\n').first; + return firstLine.replaceFirst(RegExp(r'^#\d+\s+'), ''); +} + +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() {